mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 12:54:58 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
781cb8d5b0 | ||
|
|
116b0a9190 |
+4
-3
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user