Compare commits

...
Author SHA1 Message Date
Okan YILDIRIMandpaseo-bot[bot] 781cb8d5b0 fix(server): reject plugin timeline kinds the app cannot register (#7677)
timeline.append accepted any string as the row kind, but the app registers
renderer kinds with /^[a-z][a-z0-9-]*$/. A plugin that appended a kind such as
repro.card got a successful append, then its client setup threw in
addTimelineRenderer and the whole plugin client failed to load.

The daemon now rejects an append whose kind breaks that rule and names the rule
in the error. The wire schema stays a plain string. Document the rule in the
plugin docs.

Fixes #7326

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-15 12:51:47 +00:00
paseo-bot[bot] 116b0a9190 Open the Explorer from its toggle state after a resize to phone width (#9771)
ensureExplorerSidebar clicked the toggle only when no Explorer was
visible. Right after a resize to phone width one still is for a moment:
the wide layout until the app switches, then the closed phone overlay's
first frame. The helper skipped the click and the phone Explorer, which
starts closed, never opened.

The helper now decides from the toggle's aria-expanded, and the
Explorer spec waits for the app shell to switch layouts after resizing.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-15 12:46:57 +00:00
8 changed files with 88 additions and 19 deletions
+4 -3
View File
@@ -873,9 +873,10 @@ await paseo.agents.ref(agentId).timeline.append({
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in
the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon
restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data`
must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot
be rendered intact. The daemon advertises this RPC through
restart. A row without an installed renderer shows an unavailable placeholder. `kind` must match
`/^[a-z][a-z0-9-]*$/`, the rule `addTimelineRenderer` enforces; the daemon rejects any other kind.
Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a
payload that cannot be rendered intact. The daemon advertises this RPC through
`server_info.features.pluginTimelineItems`.
## Contribute client slash commands
@@ -20,6 +20,21 @@ function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
}
const LAYOUT_VIEWPORTS = {
compact: { width: 390, height: 844 },
wide: { width: 1280, height: 900 },
};
// The previous layout stays on screen for a few renders after the resize. Wait for the shell to
// switch so the next step reads the layout it resized to.
async function resizeToLayout(
page: Parameters<typeof ensureExplorerSidebar>[0],
layout: keyof typeof LAYOUT_VIEWPORTS,
) {
await page.setViewportSize(LAYOUT_VIEWPORTS[layout]);
await expect(page.getByTestId(`${layout}-shell`)).toBeVisible();
}
async function expectExplorerActiveTabForeground(
page: Parameters<typeof ensureExplorerSidebar>[0],
) {
@@ -244,7 +259,7 @@ test("tablet prototype keeps Explorer tabs when resizing down to a phone", async
await page.screenshot({ path: testInfo.outputPath("tablet-951.png") });
await openExplorerAgentDraft(page);
await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") });
await page.setViewportSize({ width: 390, height: 844 });
await resizeToLayout(page, "compact");
await revealPhoneExplorer(page);
await expect(
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
@@ -289,10 +304,10 @@ test("retains the Explorer host through appearance changes and folding", async (
.poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe(previousColor);
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
await page.setViewportSize({ width: 390, height: 844 });
await resizeToLayout(page, "compact");
await ensureExplorerSidebar(page);
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
await page.setViewportSize({ width: 1280, height: 900 });
await resizeToLayout(page, "wide");
await expect(explorer).toBeVisible();
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
} finally {
@@ -327,7 +342,7 @@ test("reorders compact Explorer tabs with a pointer without changing selection",
const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.setViewportSize({ width: 390, height: 844 });
await resizeToLayout(page, "compact");
await openFilesPanel(page);
await reorderCompactExplorerTabs(page);
await expect(
@@ -394,9 +409,9 @@ test("updates populated code and diff appearance without losing the editor insta
.getByTestId("file-source-editor")
.elementHandle();
await text.fill(`${source}\n// retained Explorer edit`);
await page.setViewportSize({ width: 390, height: 844 });
await resizeToLayout(page, "compact");
await ensureExplorerSidebar(page);
await page.setViewportSize({ width: 1280, height: 900 });
await resizeToLayout(page, "wide");
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
await expect(text).toContainText("retained Explorer edit");
} finally {
@@ -461,9 +476,9 @@ test("retains populated Explorer chat and terminal attachments through appearanc
const terminalNode = await terminal.elementHandle();
await page.getByTestId("workspace-explorer-toggle").first().click();
await ensureExplorerSidebar(page);
await page.setViewportSize({ width: 390, height: 844 });
await resizeToLayout(page, "compact");
await ensureExplorerSidebar(page);
await page.setViewportSize({ width: 1280, height: 900 });
await resizeToLayout(page, "wide");
await cycleWorkspaceTheme(page);
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
expect(attaches).toHaveLength(1);
@@ -483,7 +498,7 @@ test("compact file sheets cover Explorer and return to its retained file list",
try {
await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n");
await gotoWorkspace(page, workspace.workspaceId);
await page.setViewportSize({ width: 390, height: 844 });
await resizeToLayout(page, "compact");
const explorer = await ensureExplorerSidebar(page);
await openFilesPanel(page);
const host = await explorer.elementHandle();
@@ -91,10 +91,13 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(toggle).toBeVisible({ timeout: 30_000 });
const explorer = explorerSidebar(page);
if ((await explorer.count()) === 0) {
// Decide from the toggle, which reports the open state. The sidebar's own visibility lags it:
// a compact layout draws its closed Explorer for a frame before hiding it.
if ((await toggle.getAttribute("aria-expanded")) !== "true") {
await toggle.click();
}
await expect(toggle).toHaveAttribute("aria-expanded", "true");
const explorer = explorerSidebar(page);
await expect(explorer).toBeVisible({ timeout: 30_000 });
return explorer;
}
@@ -1,5 +1,6 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { PluginIdSchema } from "@getpaseo/protocol/plugin-config";
export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024;
@@ -14,6 +15,15 @@ const MARKER_COST = 32;
const SHORT_STRING = 1024;
const MAX_DEPTH = 64;
// The app registers renderer kinds under the plugin ID rule, so stored kinds must use that format.
export function assertPluginTimelineKind(kind: string): void {
if (!PluginIdSchema.safeParse(kind).success) {
throw new Error(
`Plugin timeline item kind must start with a lowercase letter and contain only lowercase letters, digits, and dashes: ${JSON.stringify(kind)}`,
);
}
}
export function assertPluginTimelineDataSize(data: JsonValue): void {
const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8");
if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) {
@@ -1660,6 +1660,37 @@ describe("plugin timeline append RPC", () => {
}),
);
});
test.each(["repro.card", "Review", "review_card", "1review", ""])(
"rejects the kind %j that no client renderer can register",
async (kind) => {
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-kind",
agentId: "agent-1",
item: { type: "plugin", id: "review-1", kind, version: 1, data: {} },
});
expect(appendTimelineItem).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "rpc_error",
payload: {
requestId: "append-kind",
requestType: "agent.timeline.append.request",
error: `Request failed: Plugin timeline item kind must start with a lowercase letter and contain only lowercase letters, digits, and dashes: ${JSON.stringify(kind)}`,
code: "handler_error",
},
});
},
);
});
describe("agent detach RPC", () => {
+5 -1
View File
@@ -144,7 +144,10 @@ import {
updateAgentCommand,
} from "./agent/lifecycle-command.js";
import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js";
import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js";
import {
assertPluginTimelineDataSize,
assertPluginTimelineKind,
} from "./agent/agent-timeline-content.js";
import { parsePluginClientId } from "./plugins/plugin-session-identity.js";
import { buildAgentForkContextAttachment } from "./agent/activity-curator.js";
import { buildAgentPrompt } from "./agent/prompt-attachments.js";
@@ -8504,6 +8507,7 @@ export class Session {
): Promise<void> {
const pluginId = parsePluginClientId(this.clientId);
if (!pluginId) throw new Error("Only plugin sessions can append plugin timeline items");
assertPluginTimelineKind(msg.item.kind);
assertPluginTimelineDataSize(msg.item.data);
const { seq, epoch } = await this.agentManager.appendTimelineItem(msg.agentId, {
...msg.item,
+6 -3
View File
@@ -1279,9 +1279,12 @@ async function publishReview(agentId: string, { paseo }: PluginHandlerContext) {
The daemon stamps `pluginId` from the calling plugin session and rejects this RPC from non-plugin
sessions. The row appears live, survives timeline refetches, and keeps only the latest value for the
same plugin and `id`. If its renderer is missing, Paseo shows the existing unavailable row. Daemons
reject `data` over the limit rather than truncating it. Daemons that support this operation
advertise `server_info.features.pluginTimelineItems`.
same plugin and `id`. If its renderer is missing, Paseo shows the existing unavailable row.
`kind` starts with a lowercase letter and contains lowercase letters, numbers, or hyphens, the same
rule `addTimelineRenderer` applies. Daemons reject any other `kind`. Daemons reject `data` over the
limit rather than truncating it. Daemons that support this operation advertise
`server_info.features.pluginTimelineItems`.
## Theme and layout
+3 -1
View File
@@ -1060,7 +1060,9 @@ async function publishReview(agentId: string, { paseo }: PluginHandlerContext) {
| `version` | 正整数 | 是 | 选择渲染器契约的版本。 |
| `data` | 兼容 JSON | 是 | 渲染器负载,JSON 序列化后最多 64 KiB。 |
daemon 会根据发起调用的插件会话写入 `pluginId`,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 `id` 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。daemon 会拒绝超过限制的 `data`,而不是截断它。支持此操作的 daemon 会声明 `server_info.features.pluginTimelineItems`。
daemon 会根据发起调用的插件会话写入 `pluginId`,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 `id` 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。
`kind` 以小写字母开头,只包含小写字母、数字或连字符,与 `addTimelineRenderer` 使用的规则相同。daemon 会拒绝其他任何 `kind`。daemon 会拒绝超过限制的 `data`,而不是截断它。支持此操作的 daemon 会声明 `server_info.features.pluginTimelineItems`。
## 主题与布局 {#theme-and-layout}