mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 23:23:21 +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:
@@ -2114,7 +2114,10 @@ export class AgentManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||
async appendTimelineItem(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
): Promise<{ seq: number; epoch: string }> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
item = limitAgentTimelineItemContent(item);
|
||||
this.touchUpdatedAt(agent);
|
||||
@@ -2133,6 +2136,7 @@ export class AgentManager {
|
||||
},
|
||||
);
|
||||
await this.persistSnapshot(agent);
|
||||
return { seq: row.seq, epoch: this.timelineStore.getEpoch(agentId) };
|
||||
}
|
||||
|
||||
async emitLiveTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AgentProviderNotice,
|
||||
AgentTaskItem,
|
||||
JsonValue,
|
||||
ProviderOptions,
|
||||
ToolPolicy,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
@@ -390,6 +391,15 @@ export interface CompactionTimelineItem {
|
||||
preTokens?: number;
|
||||
}
|
||||
|
||||
export interface PluginTimelineItem {
|
||||
type: "plugin";
|
||||
id: string;
|
||||
pluginId: string;
|
||||
kind: string;
|
||||
version: number;
|
||||
data: JsonValue;
|
||||
}
|
||||
|
||||
export type AgentTimelineItem =
|
||||
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
|
||||
| { type: "assistant_message"; text: string; messageId?: string }
|
||||
@@ -397,7 +407,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 }
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
|
||||
export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024;
|
||||
|
||||
export function assertPluginTimelineDataSize(data: JsonValue): void {
|
||||
const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8");
|
||||
if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) {
|
||||
throw new Error(`Plugin timeline item data exceeds ${PLUGIN_TIMELINE_DATA_MAX_BYTES} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
|
||||
if (
|
||||
|
||||
@@ -176,6 +176,45 @@ describe("projectTimelineRows", () => {
|
||||
expect(tool?.collapsed).toContain("tool_lifecycle");
|
||||
});
|
||||
|
||||
test("replaces plugin rows with the same identity across turns", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{
|
||||
seq: 1,
|
||||
turnId: "turn-1",
|
||||
timestamp: "2026-02-13T00:00:00.000Z",
|
||||
item: {
|
||||
type: "plugin",
|
||||
id: "review-1",
|
||||
pluginId: "review",
|
||||
kind: "review",
|
||||
version: 1,
|
||||
data: { status: "running" },
|
||||
},
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
turnId: "turn-2",
|
||||
timestamp: "2026-02-13T00:00:00.100Z",
|
||||
item: {
|
||||
type: "plugin",
|
||||
id: "review-1",
|
||||
pluginId: "review",
|
||||
kind: "review",
|
||||
version: 1,
|
||||
data: { status: "complete" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const projected = projectTimelineRows({ rows, mode: "projected" });
|
||||
|
||||
expect(projected).toHaveLength(1);
|
||||
expect(projected[0]?.item).toEqual(rows[1]?.item);
|
||||
expect(projected[0]?.turnId).toBe("turn-2");
|
||||
expect(projected[0]?.sourceSeqRanges).toEqual([{ startSeq: 1, endSeq: 2 }]);
|
||||
expect(projected[0]?.collapsed).toContain("identity");
|
||||
});
|
||||
|
||||
test("returns canonical rows unchanged in canonical mode", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineRow } from "./agent-manager.js";
|
||||
import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
|
||||
|
||||
export type TimelineProjectionMode = "canonical" | "projected";
|
||||
|
||||
@@ -8,7 +9,11 @@ export interface TimelineSeqRange {
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export type TimelineProjectionKind = "assistant_merge" | "reasoning_merge" | "tool_lifecycle";
|
||||
export type TimelineProjectionKind =
|
||||
| "assistant_merge"
|
||||
| "reasoning_merge"
|
||||
| "tool_lifecycle"
|
||||
| "identity";
|
||||
export type TimelineLimitDirection = "tail" | "before" | "after";
|
||||
|
||||
export interface TimelineProjectionEntry {
|
||||
@@ -120,43 +125,68 @@ function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): WorkingEntry[]
|
||||
}));
|
||||
}
|
||||
|
||||
function collapseToolLifecycle(entries: readonly WorkingEntry[]): WorkingEntry[] {
|
||||
function mergeIdentityMetadata(
|
||||
existing: WorkingEntry,
|
||||
entry: WorkingEntry,
|
||||
collapseKind: TimelineProjectionKind,
|
||||
): Pick<WorkingEntry, "sourceSeqRanges" | "collapsed"> {
|
||||
return {
|
||||
sourceSeqRanges: mergeSeqRanges(existing.sourceSeqRanges, entry.sourceSeqRanges),
|
||||
collapsed: existing.collapsed.includes(collapseKind)
|
||||
? existing.collapsed
|
||||
: [...existing.collapsed, collapseKind],
|
||||
};
|
||||
}
|
||||
|
||||
function mergeIdentityEntries(existing: WorkingEntry, entry: WorkingEntry): WorkingEntry | null {
|
||||
switch (entry.item.type) {
|
||||
case "tool_call":
|
||||
if (existing.item.type !== "tool_call" || existing.turnId !== entry.turnId) return null;
|
||||
return {
|
||||
...existing,
|
||||
item: mergeToolCallItems(existing.item, entry.item),
|
||||
timestamp: entry.timestamp,
|
||||
seqEnd: Math.max(existing.seqEnd, entry.seqEnd),
|
||||
...mergeIdentityMetadata(existing, entry, "tool_lifecycle"),
|
||||
};
|
||||
case "plugin":
|
||||
if (existing.item.type !== "plugin") return null;
|
||||
return {
|
||||
...entry,
|
||||
seqStart: existing.seqStart,
|
||||
...mergeIdentityMetadata(existing, entry, "identity"),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collapseByIdentity(entries: readonly WorkingEntry[]): WorkingEntry[] {
|
||||
const output: WorkingEntry[] = [];
|
||||
const toolIndexByCallId = new Map<string, number>();
|
||||
const indexByIdentity = new Map<string, number>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.item.type !== "tool_call") {
|
||||
const identity = timelineItemIdentity(entry.item);
|
||||
if (identity === null) {
|
||||
output.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingIndex = toolIndexByCallId.get(entry.item.callId);
|
||||
const existingIndex = indexByIdentity.get(identity);
|
||||
if (existingIndex === undefined) {
|
||||
toolIndexByCallId.set(entry.item.callId, output.length);
|
||||
indexByIdentity.set(identity, output.length);
|
||||
output.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = output[existingIndex];
|
||||
if (!existing || existing.item.type !== "tool_call" || existing.turnId !== entry.turnId) {
|
||||
const merged = existing ? mergeIdentityEntries(existing, entry) : null;
|
||||
if (!merged) {
|
||||
indexByIdentity.set(identity, output.length);
|
||||
output.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
const mergedItem = mergeToolCallItems(existing.item, entry.item);
|
||||
const mergedRanges = mergeSeqRanges(existing.sourceSeqRanges, entry.sourceSeqRanges);
|
||||
const collapsed = existing.collapsed.includes("tool_lifecycle")
|
||||
? existing.collapsed
|
||||
: ([...existing.collapsed, "tool_lifecycle"] as TimelineProjectionKind[]);
|
||||
|
||||
output[existingIndex] = {
|
||||
...existing,
|
||||
item: mergedItem,
|
||||
timestamp: entry.timestamp,
|
||||
seqEnd: Math.max(existing.seqEnd, entry.seqEnd),
|
||||
sourceSeqRanges: mergedRanges,
|
||||
collapsed,
|
||||
};
|
||||
output[existingIndex] = merged;
|
||||
}
|
||||
|
||||
return output;
|
||||
@@ -264,7 +294,7 @@ export function projectTimelineRows(input: {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
const toolCollapsed = collapseToolLifecycle(canonical);
|
||||
const toolCollapsed = collapseByIdentity(canonical);
|
||||
const assistantMerged = mergeAssistantChunks(toolCollapsed);
|
||||
return mergeReasoningChunks(assistantMerged);
|
||||
}
|
||||
@@ -282,7 +312,7 @@ export function selectTimelineWindowByProjectedLimit(input: {
|
||||
const { rows, direction } = input;
|
||||
const limit = Math.max(0, Math.floor(input.limit));
|
||||
const canonical = makeCanonicalEntries(rows);
|
||||
const projectedAll = mergeReasoningChunks(mergeAssistantChunks(collapseToolLifecycle(canonical)));
|
||||
const projectedAll = mergeReasoningChunks(mergeAssistantChunks(collapseByIdentity(canonical)));
|
||||
|
||||
if (projectedAll.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,7 @@ const INBOUND_PERMISSION = {
|
||||
"agent.skills.save_selection.request": "daemon.manage",
|
||||
"agent.skills.uninstall.request": "daemon.manage",
|
||||
"agent.timeline.list_prompts.request": "workspace.read",
|
||||
"agent.timeline.append.request": "workspace.write",
|
||||
"agent.timeline.set_subscription.request": "workspace.read",
|
||||
agent_permission_response: "workspace.write",
|
||||
archive_agent_request: "workspace.write",
|
||||
@@ -215,6 +216,7 @@ const OUTBOUND_PERMISSION = {
|
||||
"agent.skills.save_selection.response": "daemon.manage",
|
||||
"agent.skills.uninstall.response": "daemon.manage",
|
||||
"agent.timeline.list_prompts.response": "workspace.read",
|
||||
"agent.timeline.append.response": "workspace.write",
|
||||
"agent.timeline.replacement": "workspace.read",
|
||||
"agent.timeline.set_subscription.response": "workspace.read",
|
||||
agent_archived: "workspace.read",
|
||||
|
||||
@@ -138,6 +138,13 @@ describe("plugin contribution targets", () => {
|
||||
schema: { safeParse(value) { return { success: true, data: value }; } },
|
||||
Component() { return null; },
|
||||
});
|
||||
plugin.addClientSlashCommand({
|
||||
name: "review",
|
||||
description: "Review changes",
|
||||
argumentHint: "[scope]",
|
||||
context: "agent",
|
||||
onSubmit() {},
|
||||
});
|
||||
plugin.addClientSide((client) => {
|
||||
return client.addComposerPill({
|
||||
id: "composer-card",
|
||||
@@ -156,12 +163,38 @@ describe("plugin contribution targets", () => {
|
||||
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
|
||||
expect(clientBundle).toContain("timeline-card");
|
||||
expect(clientBundle).toContain("composer-card");
|
||||
expect(clientBundle).toContain("Review changes");
|
||||
expect(serverBundle).not.toContain("timeline-card");
|
||||
expect(serverBundle).not.toContain("composer-card");
|
||||
expect(serverBundle).not.toContain("Review changes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin client runtime syntax", () => {
|
||||
it("uses the automatic JSX runtime without a React import", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const entryPath = path.join(directory, "index.tsx");
|
||||
await writeFile(
|
||||
entryPath,
|
||||
`import { Text } from "react-native";
|
||||
|
||||
function Surface() {
|
||||
return <Text>Automatic JSX</Text>;
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.addSurface("automatic-jsx", Surface);
|
||||
return () => undefined;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const { clientBundle } = await compilePlugin(entryPath);
|
||||
expect(clientBundle).toContain("react/jsx-runtime");
|
||||
expect(clientBundle).not.toContain("React.createElement");
|
||||
});
|
||||
|
||||
it("lowers async callbacks before Hermes evaluates the client bundle", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
|
||||
temporaryDirectories.push(directory);
|
||||
|
||||
@@ -83,6 +83,7 @@ const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<str
|
||||
"addSidebarItem",
|
||||
"addWorkspacePanel",
|
||||
"addCommandCenterItem",
|
||||
"addClientSlashCommand",
|
||||
"addClientSide",
|
||||
"addAttachmentSource",
|
||||
"addTheme",
|
||||
@@ -323,6 +324,7 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
|
||||
},
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
jsx: target === "client" ? "automatic" : undefined,
|
||||
platform: target === "server" ? "node" : "neutral",
|
||||
target: target === "server" ? "node20" : "es2020",
|
||||
// Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro,
|
||||
|
||||
@@ -37,6 +37,12 @@ const list = defineRpc({
|
||||
output: z.object({ agentIds: z.array(z.string()) }),
|
||||
});
|
||||
|
||||
const append = defineRpc({
|
||||
name: "append",
|
||||
input: z.object({ agentId: z.string(), status: z.string() }),
|
||||
output: z.object({ seq: z.number(), epoch: z.string() }),
|
||||
});
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(create, async ({ path }, { paseo }) => {
|
||||
const workspace = await paseo.workspaces.create({
|
||||
@@ -53,6 +59,15 @@ export default function contribute(plugin: PluginContext) {
|
||||
const result = await paseo.agents.list({ page: { limit: 100 } });
|
||||
return { agentIds: result.entries.map((entry) => entry.agent.id) };
|
||||
});
|
||||
plugin.handle(append, ({ agentId, status }, { paseo }) =>
|
||||
paseo.agents.ref(agentId).timeline.append({
|
||||
type: "plugin",
|
||||
id: "review-1",
|
||||
kind: "review",
|
||||
version: 1,
|
||||
data: { status },
|
||||
}),
|
||||
);
|
||||
return () => undefined;
|
||||
}`,
|
||||
);
|
||||
@@ -88,6 +103,22 @@ export default function contribute(plugin: PluginContext) {
|
||||
expect(listed).toEqual({
|
||||
agentIds: expect.arrayContaining([Reflect.get(created, "agentId")]),
|
||||
});
|
||||
const agentId = Reflect.get(created, "agentId");
|
||||
await expect(
|
||||
client.invokePluginRpc("paseo-api", "append", { agentId, status: "running" }),
|
||||
).resolves.toEqual({ seq: expect.any(Number), epoch: expect.any(String) });
|
||||
await client.invokePluginRpc("paseo-api", "append", { agentId, status: "complete" });
|
||||
const timeline = await client.fetchAgentTimeline(agentId, { projection: "projected" });
|
||||
expect(timeline.entries.filter((entry) => entry.item.type === "plugin")).toEqual([
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
type: "plugin",
|
||||
id: "review-1",
|
||||
pluginId: "paseo-api",
|
||||
data: { status: "complete" },
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
await client.removePlugin("paseo-api");
|
||||
const workspaces = await client.fetchWorkspaces();
|
||||
const agents = await client.fetchAgents();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { createPluginDaemonTransportFactory } from "./daemon-transport.js";
|
||||
import { isPluginClientOnlySdkSpecifier, isPluginSdkSpecifier } from "./plugin-sdk-specifiers.js";
|
||||
import { createPluginClientId } from "./plugin-session-identity.js";
|
||||
|
||||
type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise<unknown>;
|
||||
|
||||
@@ -106,7 +107,7 @@ const transportFactory = createPluginDaemonTransportFactory({
|
||||
async function initialize(message: Extract<PluginProcessRequest, { type: "initialize" }>) {
|
||||
daemonClient = new DaemonClient({
|
||||
url: `ipc://plugin/${encodeURIComponent(message.pluginId)}`,
|
||||
clientId: `plugin:${message.pluginId}`,
|
||||
clientId: createPluginClientId(message.pluginId),
|
||||
clientType: "cli",
|
||||
appVersion: message.appVersion,
|
||||
reconnect: { enabled: false },
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const PLUGIN_CLIENT_ID_PREFIX = "plugin:";
|
||||
|
||||
export function createPluginClientId(pluginId: string): string {
|
||||
return `${PLUGIN_CLIENT_ID_PREFIX}${pluginId}`;
|
||||
}
|
||||
|
||||
export function parsePluginClientId(clientId: string): string | null {
|
||||
return isPluginClientId(clientId) ? clientId.slice(PLUGIN_CLIENT_ID_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
export function isPluginClientId(clientId: string): boolean {
|
||||
return clientId.startsWith(PLUGIN_CLIENT_ID_PREFIX);
|
||||
}
|
||||
@@ -282,6 +282,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
interface SessionForTestOptions {
|
||||
clientId?: string;
|
||||
permissions?: readonly DaemonPermission[];
|
||||
agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown };
|
||||
agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown };
|
||||
@@ -360,7 +361,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
const messages = options.messages ?? [];
|
||||
|
||||
const sessionOptions: SessionOptions = {
|
||||
clientId: "test-client",
|
||||
clientId: options.clientId ?? "test-client",
|
||||
onMessage: (message) => messages.push(message),
|
||||
...(options.targetedMessages
|
||||
? {
|
||||
@@ -1375,6 +1376,100 @@ function createStoredAgentRecord(
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin timeline append RPC", () => {
|
||||
test("stamps the plugin identity and returns the timeline position", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const appendTimelineItem = vi.fn().mockResolvedValue({ seq: 7, epoch: "epoch-1" });
|
||||
const session = createSessionForTest({
|
||||
clientId: "plugin:review",
|
||||
messages,
|
||||
agentManager: { appendTimelineItem },
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "agent.timeline.append.request",
|
||||
requestId: "append-1",
|
||||
agentId: "agent-1",
|
||||
item: {
|
||||
type: "plugin",
|
||||
id: "review-1",
|
||||
kind: "review",
|
||||
version: 1,
|
||||
data: { status: "running" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(appendTimelineItem).toHaveBeenCalledWith("agent-1", {
|
||||
type: "plugin",
|
||||
id: "review-1",
|
||||
pluginId: "review",
|
||||
kind: "review",
|
||||
version: 1,
|
||||
data: { status: "running" },
|
||||
});
|
||||
expect(messages).toContainEqual({
|
||||
type: "agent.timeline.append.response",
|
||||
payload: { requestId: "append-1", seq: 7, epoch: "epoch-1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects append requests from non-plugin sessions", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const appendTimelineItem = vi.fn();
|
||||
const session = createSessionForTest({ messages, agentManager: { appendTimelineItem } });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "agent.timeline.append.request",
|
||||
requestId: "append-1",
|
||||
agentId: "agent-1",
|
||||
item: { type: "plugin", id: "review-1", kind: "review", version: 1, data: {} },
|
||||
});
|
||||
|
||||
expect(appendTimelineItem).not.toHaveBeenCalled();
|
||||
expect(messages).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "rpc_error",
|
||||
payload: expect.objectContaining({ requestId: "append-1", code: "handler_error" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects plugin data larger than the append budget", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const appendTimelineItem = vi.fn();
|
||||
const session = createSessionForTest({
|
||||
clientId: "plugin:review",
|
||||
messages,
|
||||
agentManager: { appendTimelineItem },
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "agent.timeline.append.request",
|
||||
requestId: "append-large",
|
||||
agentId: "agent-1",
|
||||
item: {
|
||||
type: "plugin",
|
||||
id: "review-1",
|
||||
kind: "review",
|
||||
version: 1,
|
||||
data: { text: "x".repeat(64 * 1024) },
|
||||
},
|
||||
});
|
||||
|
||||
expect(appendTimelineItem).not.toHaveBeenCalled();
|
||||
expect(messages).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "rpc_error",
|
||||
payload: expect.objectContaining({
|
||||
requestId: "append-large",
|
||||
code: "handler_error",
|
||||
error: expect.stringContaining("65536 bytes"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent detach RPC", () => {
|
||||
test("detaches a stored subagent and emits the updated standalone agent", async () => {
|
||||
const messages: unknown[] = [];
|
||||
|
||||
@@ -107,6 +107,8 @@ import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
} from "./agent/timeline-append.js";
|
||||
import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js";
|
||||
import { parsePluginClientId } from "./plugins/plugin-session-identity.js";
|
||||
import {
|
||||
projectTimelineRows,
|
||||
selectProjectedTimelinePage,
|
||||
@@ -2252,6 +2254,8 @@ export class Session {
|
||||
switch (msg.type) {
|
||||
case "fetch_agent_timeline_request":
|
||||
return this.handleFetchAgentTimelineRequest(msg, source);
|
||||
case "agent.timeline.append.request":
|
||||
return this.handleAgentTimelineAppendRequest(msg);
|
||||
case "agent.timeline.list_prompts.request":
|
||||
return this.handleAgentTimelineListPromptsRequest(msg, source);
|
||||
case "agent.provider_subagents.list.request":
|
||||
@@ -7097,6 +7101,22 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAgentTimelineAppendRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "agent.timeline.append.request" }>,
|
||||
): Promise<void> {
|
||||
const pluginId = parsePluginClientId(this.clientId);
|
||||
if (!pluginId) throw new Error("Only plugin sessions can append plugin timeline items");
|
||||
assertPluginTimelineDataSize(msg.item.data);
|
||||
const { seq, epoch } = await this.agentManager.appendTimelineItem(msg.agentId, {
|
||||
...msg.item,
|
||||
pluginId,
|
||||
});
|
||||
this.emit({
|
||||
type: "agent.timeline.append.response",
|
||||
payload: { requestId: msg.requestId, seq, epoch },
|
||||
});
|
||||
}
|
||||
|
||||
private async handleAgentTimelineListPromptsRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "agent.timeline.list_prompts.request" }>,
|
||||
source?: object,
|
||||
|
||||
@@ -49,6 +49,7 @@ import type {
|
||||
} from "./workspace-git-service.js";
|
||||
import type { GitCommandRuntimeMetricsSnapshot } from "../utils/git-command-runtime-metrics.js";
|
||||
import { snapshotGitCommandRuntimeMetrics } from "../utils/run-git-command.js";
|
||||
import { createPluginClientId, isPluginClientId } from "./plugins/plugin-session-identity.js";
|
||||
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||
import {
|
||||
@@ -1521,10 +1522,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
const pluginId = this.pluginSocketIds.get(ws);
|
||||
const expectedPluginClientId = pluginId ? `plugin:${pluginId}` : null;
|
||||
const expectedPluginClientId = pluginId ? createPluginClientId(pluginId) : null;
|
||||
if (
|
||||
(expectedPluginClientId !== null && clientId !== expectedPluginClientId) ||
|
||||
(expectedPluginClientId === null && clientId.startsWith("plugin:"))
|
||||
(expectedPluginClientId === null && isPluginClientId(clientId))
|
||||
) {
|
||||
this.clearPendingConnection(ws);
|
||||
pending.connectionLogger.warn({ clientId }, "Rejected reserved plugin clientId");
|
||||
@@ -1670,6 +1671,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
pluginLogs: true,
|
||||
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
|
||||
pluginThemes: true,
|
||||
pluginTimelineItems: true,
|
||||
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
|
||||
skillManagement: true,
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
|
||||
Reference in New Issue
Block a user