mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d75b64ff61 | ||
|
|
06cdc5a0b8 | ||
|
|
4a32f95848 | ||
|
|
1b450e487e | ||
|
|
74c8172f8b | ||
|
|
fa70b215a8 |
@@ -0,0 +1,53 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function openReportedFolderLink(page: Page, name: string) {
|
||||
const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true });
|
||||
await link.click();
|
||||
}
|
||||
|
||||
async function expectFolderContents(page: Page) {
|
||||
await expect(page.getByText("Requested path is not a file", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-explorer-tree-scroll").getByText("guide.md", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
for (const folder of [
|
||||
"./docs/guides",
|
||||
"./docs/guides/",
|
||||
"./docs/reports.v1",
|
||||
"./.github/workflows",
|
||||
"docs/guides",
|
||||
"docs/guides/",
|
||||
"docs/指南",
|
||||
]) {
|
||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "folder-link-repro-",
|
||||
title: "Folder link reproduction",
|
||||
initialPrompt: "Show the folder link",
|
||||
featureValues: { mockAssistantResponse: `[Open folder](${folder})` },
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"panel-state",
|
||||
JSON.stringify({ state: { explorerShowHiddenFiles: false }, version: 0 }),
|
||||
);
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await openReportedFolderLink(page, "Open folder");
|
||||
await expectFolderContents(page);
|
||||
} finally {
|
||||
await page.screenshot({ path: test.info().outputPath("folder-link-result.png") });
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -316,6 +317,47 @@ export default function contribute(plugin) {
|
||||
return directory;
|
||||
}
|
||||
|
||||
/** Build command that holds the plugin lifecycle queue until the test writes the release file. */
|
||||
const gatedBuildCommand = [
|
||||
'const fs = require("node:fs");',
|
||||
'fs.writeFileSync(process.argv[1], "started");',
|
||||
"const wait = () => {",
|
||||
" if (fs.existsSync(process.argv[2])) return;",
|
||||
" setTimeout(wait, 50);",
|
||||
"};",
|
||||
"wait();",
|
||||
].join("\n");
|
||||
|
||||
async function createGatedBuildRepository(
|
||||
root: string,
|
||||
gate: { started: string; release: string },
|
||||
): Promise<string> {
|
||||
const repository = path.join(root, "gated-repository");
|
||||
await mkdir(repository);
|
||||
await writeFile(
|
||||
path.join(repository, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "gated-build-plugin",
|
||||
description: "Holds the plugin lifecycle queue while its build waits",
|
||||
requirements: pluginRequirements,
|
||||
build: [[process.execPath, "-e", gatedBuildCommand, gate.started, gate.release]],
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Gated build plugin"));
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Paseo Tests"], {
|
||||
cwd: repository,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "user.email", "paseo@example.test"], {
|
||||
cwd: repository,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" });
|
||||
return repository;
|
||||
}
|
||||
|
||||
async function installFailedPlugin(
|
||||
page: Page,
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
@@ -680,3 +722,155 @@ async function installPluginWithUntrustedDescription(
|
||||
);
|
||||
await environment.client.installPluginSource({ source: directory });
|
||||
}
|
||||
|
||||
/** Waits for the reload request and the daemon's answer on the wire, so a scenario can measure it. */
|
||||
function observePluginReload(page: Page) {
|
||||
const requests: number[] = [];
|
||||
const responses: number[] = [];
|
||||
const isSessionMessage = (payload: unknown, type: string): boolean => {
|
||||
if (typeof payload !== "string") return false;
|
||||
try {
|
||||
const envelope = JSON.parse(payload) as { type?: unknown; message?: { type?: unknown } };
|
||||
const message = envelope.type === "session" ? envelope.message : envelope;
|
||||
return message?.type === type;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
page.on("websocket", (socket) => {
|
||||
// The reload request travels client -> daemon (outbound); the answer comes back inbound.
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
if (isSessionMessage(payload, "plugin.reload.request")) requests.push(Date.now());
|
||||
});
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
if (isSessionMessage(payload, "plugin.reload.response")) responses.push(Date.now());
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1),
|
||||
waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1),
|
||||
requestedAt: () => requests[0]!,
|
||||
requestCount: () => requests.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts a gated git install that holds the daemon's plugin lifecycle queue until it is released. */
|
||||
async function holdPluginLifecycleQueue(
|
||||
client: PluginEnvironment["client"],
|
||||
root: string,
|
||||
): Promise<{ release: () => Promise<void>; settle: () => Promise<void> }> {
|
||||
const gate = {
|
||||
started: path.join(root, "build-started"),
|
||||
release: path.join(root, "build-release"),
|
||||
};
|
||||
const repository = await createGatedBuildRepository(root, gate);
|
||||
const install = client.installPluginSource({ source: `git:${pathToFileURL(repository).href}` });
|
||||
// The marker wait below can outlive an early failure: a refused preparation, a dropped
|
||||
// connection, or a build command that exits before writing its marker. Handle that rejection now
|
||||
// so it never surfaces as an unhandled one, and drain the same promise in finish().
|
||||
const installDrained = install.catch(() => undefined);
|
||||
// Every path releases the gate and drains the install: a build still polling for its gate holds
|
||||
// the daemon's plugin lifecycle queue open, and the fixture's own plugin removal would stall.
|
||||
const finish = async () => {
|
||||
await writeFile(gate.release, "go").catch(() => undefined);
|
||||
await installDrained;
|
||||
};
|
||||
try {
|
||||
// Whichever comes first decides the scenario: the build reports that it started, or the setup
|
||||
// fails with the daemon's own error instead of a bare marker timeout.
|
||||
await Promise.race([
|
||||
expect.poll(() => existsSync(gate.started), { timeout: 30_000 }).toBe(true),
|
||||
install,
|
||||
]);
|
||||
} catch (error) {
|
||||
// Setup that never reached the build must not hand the caller a queue it cannot settle.
|
||||
await finish();
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
release: () => writeFile(gate.release, "go"),
|
||||
settle: finish,
|
||||
};
|
||||
}
|
||||
|
||||
function pluginRowActions(page: Page, pluginId: string) {
|
||||
return page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true });
|
||||
}
|
||||
|
||||
async function expectPluginRowPending(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRowActions(page, pluginId)).toBeDisabled();
|
||||
}
|
||||
|
||||
async function expectPluginRowSettled(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRowActions(page, pluginId)).toBeEnabled();
|
||||
await expect(page.getByLabel(`${pluginId} running`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(name),
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */
|
||||
async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise<void> {
|
||||
const remaining = 66_000 - (Date.now() - requestedAt);
|
||||
if (remaining > 0) await page.waitForTimeout(remaining);
|
||||
}
|
||||
|
||||
test("keeps Reload pending past a minute while the plugin queue is busy", async ({
|
||||
page,
|
||||
pluginEnvironment,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const { client, directory } = pluginEnvironment;
|
||||
const pluginDirectory = await createDirectoryPlugin(
|
||||
directory,
|
||||
"e2e-plugin",
|
||||
"Exercises the complete local plugin lifecycle",
|
||||
"Plugin v1",
|
||||
);
|
||||
|
||||
// Observe before the app loads: its daemon socket comes up with the page.
|
||||
const reload = observePluginReload(page);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
// Plugins on is a precondition here, not the subject: set it through the daemon instead of the
|
||||
// switch, whose transient "Plugins enabled" confirmation this scenario must not depend on.
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: pluginDirectory });
|
||||
await expectPluginRowSettled(page, "e2e-plugin");
|
||||
|
||||
const queue = await holdPluginLifecycleQueue(client, directory);
|
||||
try {
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await reload.waitForRequest();
|
||||
// Pending: the row's actions stay disabled, so a second reload cannot be submitted.
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
|
||||
|
||||
await waitPastFormerReloadDeadline(page, reload.requestedAt());
|
||||
|
||||
// Past the former deadline the reload is still pending, the row shows its previous state, and
|
||||
// no timeout error reached the surface.
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toHaveCount(0);
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-past-deadline.png");
|
||||
|
||||
await queue.release();
|
||||
// The daemon's answer only arrives once the plugin really started again; a request timeout
|
||||
// would have shown up as feedback during the wait above.
|
||||
await reload.waitForResponse();
|
||||
expect(reload.requestCount()).toBe(1);
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
||||
"Reloaded e2e-plugin",
|
||||
);
|
||||
await expectPluginRowSettled(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-completed.png");
|
||||
} finally {
|
||||
await queue.settle();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,13 +87,10 @@ import {
|
||||
} from "./bottom-anchor-controller";
|
||||
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
|
||||
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
|
||||
import {
|
||||
AssistantFileLinkResolverProvider,
|
||||
normalizeInlinePathTarget,
|
||||
} from "@/assistant-file-links";
|
||||
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
type WorkspaceFileLocation,
|
||||
type OpenFileDisposition,
|
||||
type WorkspaceFileOpenRequest,
|
||||
} from "@/workspace/file-open";
|
||||
@@ -387,7 +384,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const workspaceRoot = context.cwd?.trim() || "";
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
const { revealDirectory } = useFileExplorerActions({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId,
|
||||
workspaceRoot,
|
||||
@@ -426,63 +423,53 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setExpandedToolCallGroupIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const isActive = useRetainedPanelActive();
|
||||
const openInlineFile = useStableEvent(
|
||||
(location: WorkspaceFileLocation, disposition: OpenFileDisposition) => {
|
||||
if (onOpenWorkspaceFile) {
|
||||
onOpenWorkspaceFile({ location, disposition });
|
||||
} else if (context.workspaceId) {
|
||||
navigateToWorkspace({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId,
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
const openInlineDirectory = useStableEvent((directoryPath: string) => {
|
||||
void revealDirectory(directoryPath);
|
||||
openExplorerSidebarView({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: buildWorkspaceTabPersistenceKey({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId ?? "",
|
||||
}),
|
||||
checkout: {
|
||||
serverId: resolvedServerId,
|
||||
cwd: context.cwd,
|
||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
||||
},
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const inlinePathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
workspaceRoot,
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
}),
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isActive) inlinePathOpener.cancel();
|
||||
return () => inlinePathOpener.cancel();
|
||||
}, [inlinePathOpener, isActive, agentId]);
|
||||
const handleInlinePathPress = useStableEvent(
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
if (!target.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeInlinePathTarget(target.path, context.cwd);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized.file) {
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
lineStart: target.lineStart,
|
||||
lineEnd: target.lineEnd,
|
||||
});
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onOpenWorkspaceFile) {
|
||||
onOpenWorkspaceFile({
|
||||
location,
|
||||
disposition,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.workspaceId) {
|
||||
navigateToWorkspace({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId,
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void requestDirectoryListing(normalized.directory, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
|
||||
openExplorerSidebarView({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: buildWorkspaceTabPersistenceKey({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId ?? "",
|
||||
}),
|
||||
checkout: {
|
||||
serverId: resolvedServerId,
|
||||
cwd: context.cwd,
|
||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
||||
},
|
||||
view: "files",
|
||||
});
|
||||
if (isActive) void inlinePathOpener.open(target, disposition);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -519,7 +506,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
// cell-window and turn-lifecycle renders from background agents.
|
||||
// When isActive flips back to true, the context change triggers a re-render and
|
||||
// the component reads the current (fresh) streamItems/streamHead from props.
|
||||
const isActive = useRetainedPanelActive();
|
||||
const effectiveStreamItems = useRetainedValue(streamItems, isActive);
|
||||
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
|
||||
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
|
||||
|
||||
@@ -10,3 +10,5 @@ export {
|
||||
} from "./provider";
|
||||
export type { AssistantFileLinkSource } from "./resolver";
|
||||
export { useAssistantFileLinkActions, useFileLink } from "./use-file-link";
|
||||
|
||||
export { createInlinePathOpener } from "./path-open";
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface AssistantHrefParseOptions {
|
||||
* send a real file named `my%20file.md` to `my file.md`.
|
||||
*/
|
||||
decodeHref?: boolean;
|
||||
/** Explicit Markdown links can name nested folders without a filename extension. */
|
||||
allowExtensionlessNestedPaths?: boolean;
|
||||
}
|
||||
|
||||
export type AssistantFileLinkClassification =
|
||||
@@ -278,6 +280,7 @@ export function parseAssistantFileLink(
|
||||
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
decodeHref: options.decodeHref,
|
||||
allowExtensionlessNestedPaths: options.allowExtensionlessNestedPaths,
|
||||
});
|
||||
if (relativeTarget) {
|
||||
return relativeTarget;
|
||||
@@ -411,7 +414,7 @@ function parseWorkspaceRelativeFileLink(
|
||||
|
||||
function parseLocalPathParts(
|
||||
value: string,
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref" | "allowExtensionlessNestedPaths"> = {},
|
||||
): {
|
||||
path: string;
|
||||
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
|
||||
@@ -437,7 +440,7 @@ function parseLocalPathParts(
|
||||
if (options.decodeHref) {
|
||||
pathPart = safeDecodeURIComponent(pathPart);
|
||||
}
|
||||
if (!isPlausibleAssistantLocalPath(pathPart)) {
|
||||
if (!isPlausibleAssistantLocalPath(pathPart, options)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -458,7 +461,7 @@ function parseLocalPathParts(
|
||||
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
|
||||
}
|
||||
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
|
||||
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
|
||||
if (!isPlausibleAssistantLocalPath(plausiblePath, options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -583,7 +586,10 @@ function getHeuristicLocalPath(value: string): string | null {
|
||||
return beforeHash;
|
||||
}
|
||||
|
||||
function isPlausibleAssistantLocalPath(pathValue: string): boolean {
|
||||
function isPlausibleAssistantLocalPath(
|
||||
pathValue: string,
|
||||
options: Pick<AssistantHrefParseOptions, "allowExtensionlessNestedPaths"> = {},
|
||||
): boolean {
|
||||
const normalized = normalizePathToken(pathValue);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
@@ -607,7 +613,10 @@ function isPlausibleAssistantLocalPath(pathValue: string): boolean {
|
||||
|
||||
if (segments.length > 1) {
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
return !isDomainLikePathSegment(firstSegment) && isPlausibleAssistantFileName(lastSegment);
|
||||
return (
|
||||
!isDomainLikePathSegment(firstSegment) &&
|
||||
(options.allowExtensionlessNestedPaths === true || isPlausibleAssistantFileName(lastSegment))
|
||||
);
|
||||
}
|
||||
|
||||
return isPlausibleAssistantFileName(firstSegment);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { resolveInlinePathOpen } from "./path-open";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("distinguishes dotted and extensionless folders from files using the filesystem", async () => {
|
||||
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "folder-link-"));
|
||||
roots.push(workspaceRoot);
|
||||
await mkdir(path.join(workspaceRoot, "docs/guides"), { recursive: true });
|
||||
await mkdir(path.join(workspaceRoot, "docs/reports.v1"));
|
||||
await writeFile(path.join(workspaceRoot, "Makefile"), "all:\n");
|
||||
await writeFile(path.join(workspaceRoot, "README.md"), "# Readme\n");
|
||||
const client = {
|
||||
listDirectory: (root: string, file: string) => readdir(path.resolve(root, file)),
|
||||
};
|
||||
for (const file of ["docs/guides", "docs/reports.v1"]) {
|
||||
await expect(
|
||||
resolveInlinePathOpen({ file, directory: "docs", workspaceRoot, client }),
|
||||
).resolves.toEqual({ kind: "directory", directoryPath: file });
|
||||
}
|
||||
for (const file of ["Makefile", "README.md", "missing.md"]) {
|
||||
await expect(
|
||||
resolveInlinePathOpen({ file, directory: ".", workspaceRoot, client }),
|
||||
).resolves.toEqual({ kind: "file" });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves explicit directory destinations and file line references", async () => {
|
||||
const workspaceRoot = "/repo";
|
||||
await expect(
|
||||
resolveInlinePathOpen({ directory: "docs/guides", workspaceRoot, client: null }),
|
||||
).resolves.toEqual({ kind: "directory", directoryPath: "docs/guides" });
|
||||
await expect(
|
||||
resolveInlinePathOpen({
|
||||
file: "docs/guides",
|
||||
directory: "docs",
|
||||
lineStart: 12,
|
||||
workspaceRoot,
|
||||
client: null,
|
||||
}),
|
||||
).resolves.toEqual({ kind: "file" });
|
||||
});
|
||||
|
||||
it("keeps unavailable probes and external paths on the existing file-open path", async () => {
|
||||
for (const file of ["Makefile", "/external/folder", "C:/external/folder"]) {
|
||||
await expect(
|
||||
resolveInlinePathOpen({ file, directory: ".", workspaceRoot: "/repo", client: null }),
|
||||
).resolves.toEqual({ kind: "file" });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores an older probe after a newer file link opens", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
let rejectProbe!: (error: Error) => void;
|
||||
const pendingProbe = new Promise<never>((_, reject) => {
|
||||
rejectProbe = reject;
|
||||
});
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
|
||||
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
|
||||
rejectProbe(new Error("Not a directory"));
|
||||
await first;
|
||||
expect(opened).toEqual(["second.md"]);
|
||||
});
|
||||
|
||||
it("ignores a pending probe when its view is left", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
let resolveProbe!: (value: unknown) => void;
|
||||
const pendingProbe = new Promise((resolve) => {
|
||||
resolveProbe = resolve;
|
||||
});
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
|
||||
opener.cancel();
|
||||
resolveProbe({});
|
||||
await pending;
|
||||
expect(opened).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { normalizeInlinePathTarget, type InlinePathTarget } from "./parse";
|
||||
import {
|
||||
normalizeWorkspaceFileLocation,
|
||||
type WorkspaceFileLocation,
|
||||
type OpenFileDisposition,
|
||||
} from "@/workspace/file-open";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
interface PathOpenInput {
|
||||
file?: string;
|
||||
directory: string;
|
||||
lineStart?: number;
|
||||
workspaceRoot: string;
|
||||
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
|
||||
}
|
||||
|
||||
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
|
||||
|
||||
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
|
||||
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
|
||||
if (!input.file) {
|
||||
return { kind: "directory", directoryPath: input.directory };
|
||||
}
|
||||
if (
|
||||
input.lineStart !== undefined ||
|
||||
isAbsolutePath(input.file) ||
|
||||
!input.workspaceRoot.trim() ||
|
||||
!input.client
|
||||
) {
|
||||
return { kind: "file" };
|
||||
}
|
||||
try {
|
||||
await input.client.listDirectory(input.workspaceRoot, input.file);
|
||||
} catch {
|
||||
return { kind: "file" };
|
||||
}
|
||||
return { kind: "directory", directoryPath: input.file };
|
||||
}
|
||||
|
||||
export function createInlinePathOpener(input: {
|
||||
workspaceRoot: string;
|
||||
client: PathOpenInput["client"];
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
}) {
|
||||
let generation = 0;
|
||||
return {
|
||||
async open(target: InlinePathTarget, disposition: OpenFileDisposition) {
|
||||
const ownGeneration = ++generation;
|
||||
const normalized = normalizeInlinePathTarget(target.path, input.workspaceRoot);
|
||||
if (!normalized) return;
|
||||
const destination = await resolveInlinePathOpen({
|
||||
...normalized,
|
||||
lineStart: target.lineStart,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
client: input.client,
|
||||
});
|
||||
if (ownGeneration !== generation) return;
|
||||
if (destination.kind === "directory") {
|
||||
input.openDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
lineStart: target.lineStart,
|
||||
lineEnd: target.lineEnd,
|
||||
});
|
||||
if (location) input.openFile(location, disposition);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
generation += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,48 @@ const CONTEXT: AssistantFileLinkContext = {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
};
|
||||
|
||||
it.each(["docs/guides", "docs/guides/", "docs/%E6%8C%87%E5%8D%97"])(
|
||||
"resolves an explicit extensionless folder href: %s",
|
||||
(href) => {
|
||||
expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: { path: `${CONTEXT.workspaceRoot}/${decodeURIComponent(href).replace(/\/$/, "")}` },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["docs/guides", "origin/main"])("leaves extensionless inline code alone: %s", (text) => {
|
||||
expect(classifyForResolution({ href: text, text, sourceType: "inline-code" }, CONTEXT)).toEqual({
|
||||
kind: "resolved",
|
||||
value: { kind: "ignored" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([{ markup: "linkify" }, { sourceInfo: "auto" }])(
|
||||
"keeps auto-recognized extensionless text ignored: %j",
|
||||
(origin) => {
|
||||
expect(
|
||||
classifyForResolution({ href: "docs/guides", text: "docs/guides", ...origin }, CONTEXT),
|
||||
).toEqual({
|
||||
kind: "resolved",
|
||||
value: { kind: "ignored" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["example.com/guides", "docs/guides?view=all"])(
|
||||
"does not turn URL-like hrefs into workspace paths: %s",
|
||||
(href) => {
|
||||
expect(classifyForResolution({ href }, CONTEXT)).toEqual({
|
||||
kind: "resolved",
|
||||
value: { kind: "ignored" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
|
||||
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
|
||||
try {
|
||||
|
||||
@@ -117,6 +117,7 @@ export function classifyForResolution(
|
||||
const classification = classifyAssistantFileLink(token, {
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
decodeHref: tokenInfo.fromHref,
|
||||
allowExtensionlessNestedPaths: tokenInfo.fromHref && !isLinkifiedSource(source),
|
||||
});
|
||||
if (!classification) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
|
||||
@@ -2226,7 +2226,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
},
|
||||
// Lifts the composer off the chat it sits under on phones.
|
||||
inputWrapperCompact: {
|
||||
boxShadow: "0px 4px 16px rgba(0, 0, 0, 0.2)",
|
||||
...theme.shadow.sm,
|
||||
},
|
||||
// Dotted says "this surface is the same box, but there is nothing to type
|
||||
// into it" without swapping the border colour, which reads as an error state.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
|
||||
import type { SortOption } from "@/stores/panel-store/state";
|
||||
import { filterVisibleExplorerEntries } from "./visibility";
|
||||
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility";
|
||||
|
||||
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
|
||||
|
||||
@@ -201,3 +201,24 @@ function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption):
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/** Load and expand the path to a directory selected outside the tree. */
|
||||
export async function revealExplorerDirectory(input: {
|
||||
directoryPath: string;
|
||||
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
|
||||
expandPaths: (paths: string[]) => void;
|
||||
showHiddenFiles: () => void;
|
||||
}): Promise<void> {
|
||||
if (isHiddenExplorerPath(input.directoryPath)) input.showHiddenFiles();
|
||||
const paths = ["."];
|
||||
if (input.directoryPath !== ".") {
|
||||
const segments = input.directoryPath.split("/").filter(Boolean);
|
||||
for (let length = 1; length <= segments.length; length += 1) {
|
||||
paths.push(segments.slice(0, length).join("/"));
|
||||
}
|
||||
}
|
||||
for (const path of paths) {
|
||||
if (!(await input.requestDirectoryListing(path))) return;
|
||||
}
|
||||
input.expandPaths(paths);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
type ExplorerDirectory,
|
||||
} from "@/stores/session-store";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { revealExplorerDirectory } from "@/file-explorer/tree";
|
||||
import { parentExplorerPath } from "@/utils/explorer-paths";
|
||||
|
||||
function createExplorerState(): AgentFileExplorerState {
|
||||
@@ -172,6 +174,27 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
|
||||
);
|
||||
|
||||
const revealDirectory = useCallback(
|
||||
async (directoryPath: string) => {
|
||||
if (!workspaceStateKey) return;
|
||||
await revealExplorerDirectory({
|
||||
directoryPath,
|
||||
requestDirectoryListing,
|
||||
showHiddenFiles: () => {
|
||||
const panel = usePanelStore.getState();
|
||||
if (!panel.explorerShowHiddenFiles) panel.toggleExplorerShowHiddenFiles();
|
||||
},
|
||||
expandPaths: (paths) =>
|
||||
usePanelStore
|
||||
.getState()
|
||||
.setExpandedPathsForWorkspace(workspaceStateKey, (current) =>
|
||||
Array.from(new Set([...current, ...paths])),
|
||||
),
|
||||
});
|
||||
},
|
||||
[requestDirectoryListing, workspaceStateKey],
|
||||
);
|
||||
|
||||
const requestFilePreview = useCallback(
|
||||
async (path: string) => {
|
||||
if (!workspaceStateKey) {
|
||||
@@ -354,6 +377,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
|
||||
return {
|
||||
workspaceStateKey,
|
||||
revealDirectory,
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
requestFileDownloadToken,
|
||||
|
||||
@@ -741,7 +741,7 @@ export const darkPureBlackTheme = buildDarkTheme(pureBlackDarkColors);
|
||||
|
||||
const lightShadow = {
|
||||
sm: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.02)",
|
||||
shadowColor: "rgba(0, 0, 0, 0.08)",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
|
||||
@@ -29,6 +29,116 @@ async function git(cwd: string, args: string[]): Promise<void> {
|
||||
await execFileAsync("git", args, { cwd });
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
condition: () => Promise<boolean>,
|
||||
description: string,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await condition()) return;
|
||||
await sleep(50);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${description}`);
|
||||
}
|
||||
|
||||
interface ObservedRequest {
|
||||
type: string;
|
||||
message: Record<string, unknown>;
|
||||
observedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes real CLI requests while forwarding them to the isolated daemon.
|
||||
*
|
||||
* Requests are recorded where the CLI sends them, so a scenario can measure a wait from the
|
||||
* request boundary instead of from process start.
|
||||
*/
|
||||
async function startRequestObserver(upstreamPort: number): Promise<{
|
||||
port: number;
|
||||
requests: ObservedRequest[];
|
||||
waitForRequest: (type: string) => Promise<number>;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
const requests: ObservedRequest[] = [];
|
||||
server.on("connection", (socket, request) => {
|
||||
const upstream = new WebSocket(`ws://127.0.0.1:${upstreamPort}${request.url}`, {
|
||||
headers: { authorization: request.headers.authorization ?? "" },
|
||||
});
|
||||
const ready = once(upstream, "open");
|
||||
socket.on("message", async (data, isBinary) => {
|
||||
const frame = JSON.parse(data.toString()) as {
|
||||
type?: string;
|
||||
message?: { type?: string } & Record<string, unknown>;
|
||||
};
|
||||
if (frame.type === "session" && typeof frame.message?.type === "string") {
|
||||
requests.push({
|
||||
type: frame.message.type,
|
||||
message: frame.message,
|
||||
observedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
await ready;
|
||||
upstream.send(data, { binary: isBinary });
|
||||
});
|
||||
upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary }));
|
||||
socket.on("close", () => upstream.close());
|
||||
upstream.on("close", () => socket.close());
|
||||
});
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
return {
|
||||
port: address.port,
|
||||
requests,
|
||||
waitForRequest: async (type: string) => {
|
||||
await waitFor(
|
||||
async () => requests.some((entry) => entry.type === type),
|
||||
`${type} to reach the daemon`,
|
||||
);
|
||||
return requests.find((entry) => entry.type === type)!.observedAt;
|
||||
},
|
||||
close: async () => {
|
||||
for (const socket of server.clients) socket.close();
|
||||
await promisify(server.close.bind(server))();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build command that blocks the plugin lifecycle queue until the test writes the gate file. */
|
||||
const gateWaitBuild = [
|
||||
'const fs = require("node:fs");',
|
||||
'fs.writeFileSync(process.argv[1], "started");',
|
||||
"const wait = () => {",
|
||||
" if (fs.existsSync(process.argv[2])) return;",
|
||||
" setTimeout(wait, 50);",
|
||||
"};",
|
||||
"wait();",
|
||||
].join("\n");
|
||||
|
||||
/** Plugin whose every activation appends one line, so a test can count real reactivations. */
|
||||
function activationCountingPlugin(logPath: string): string {
|
||||
return [
|
||||
'import { appendFileSync } from "node:fs";',
|
||||
`const activationLog = ${JSON.stringify(logPath)};`,
|
||||
"export default function contribute(plugin: unknown) {",
|
||||
' appendFileSync(activationLog, "activate\\n");',
|
||||
" void plugin;",
|
||||
" return () => undefined;",
|
||||
"}",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function countActivations(logPath: string): Promise<number> {
|
||||
const content = await readFile(logPath, "utf8").catch(() => "");
|
||||
return content.split("\n").filter(Boolean).length;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-cli-e2e-"));
|
||||
const gitDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-cli-e2e-"));
|
||||
@@ -48,31 +158,11 @@ async function main(): Promise<void> {
|
||||
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
|
||||
|
||||
// Observe real CLI requests while forwarding them to the isolated daemon.
|
||||
const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
const sources: string[] = [];
|
||||
proxy.on("connection", (socket, request) => {
|
||||
const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, {
|
||||
headers: { authorization: request.headers.authorization ?? "" },
|
||||
});
|
||||
const ready = once(upstream, "open");
|
||||
socket.on("message", async (data, isBinary) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (
|
||||
message.type === "session" &&
|
||||
message.message.type === "plugin.source.install.request"
|
||||
) {
|
||||
sources.push(message.message.source);
|
||||
}
|
||||
await ready;
|
||||
upstream.send(data, { binary: isBinary });
|
||||
});
|
||||
upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary }));
|
||||
socket.on("close", () => upstream.close());
|
||||
upstream.on("close", () => socket.close());
|
||||
});
|
||||
await once(proxy, "listening");
|
||||
const address = proxy.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const observer = await startRequestObserver(context.port);
|
||||
const installedSources = () =>
|
||||
observer.requests
|
||||
.filter((entry) => entry.type === "plugin.source.install.request")
|
||||
.map((entry) => entry.message.source);
|
||||
const sibling = path.join(context.workDir, "x");
|
||||
const caller = path.join(context.workDir, "caller");
|
||||
await mkdir(caller);
|
||||
@@ -96,12 +186,12 @@ async function main(): Promise<void> {
|
||||
["~", context.paseoHome, context.paseoHome, []],
|
||||
["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []],
|
||||
] as const) {
|
||||
sources.length = 0;
|
||||
observer.requests.length = 0;
|
||||
const result = await context.paseo(
|
||||
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"],
|
||||
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${observer.port}`, "--json"],
|
||||
{ cwd: caller },
|
||||
);
|
||||
assert.deepEqual(sources, [expectedSource]);
|
||||
assert.deepEqual(installedSources(), [expectedSource]);
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).path, expectedDirectory);
|
||||
const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]);
|
||||
@@ -109,17 +199,16 @@ async function main(): Promise<void> {
|
||||
}
|
||||
// An absolute path belongs to the daemon's platform, which can differ from the CLI's.
|
||||
const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`;
|
||||
sources.length = 0;
|
||||
observer.requests.length = 0;
|
||||
const missing = await context.paseo(
|
||||
["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"],
|
||||
["plugin", "add", windowsSource, "--host", `127.0.0.1:${observer.port}`, "--json"],
|
||||
{ cwd: caller },
|
||||
);
|
||||
assert.deepEqual(sources, [windowsSource]);
|
||||
assert.deepEqual(installedSources(), [windowsSource]);
|
||||
assert.equal(missing.exitCode, 1);
|
||||
assert.match(missing.stderr, /Plugin directory does not exist/);
|
||||
} finally {
|
||||
for (const socket of proxy.clients) socket.close();
|
||||
await promisify(proxy.close.bind(proxy))();
|
||||
await observer.close();
|
||||
}
|
||||
|
||||
const install = await context.paseo(["plugin", "install", directory, "--json"]);
|
||||
@@ -242,6 +331,134 @@ async function main(): Promise<void> {
|
||||
assert.equal(enable.exitCode, 0, enable.stderr);
|
||||
assert.equal(JSON.parse(enable.stdout).status, "running");
|
||||
|
||||
// A reload that finishes after the client's former one-minute deadline must still report its
|
||||
// real result. The wait is the daemon's own lifecycle queue, so it runs on real wall-clock time.
|
||||
const gateDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-reload-e2e-"));
|
||||
const buildStarted = path.join(gateDirectory, "build-started");
|
||||
const buildGate = path.join(gateDirectory, "build-gate");
|
||||
const activationLog = path.join(gateDirectory, "activations.log");
|
||||
const targetDirectory = path.join(gateDirectory, "slow-reload-target");
|
||||
const slowRepository = path.join(gateDirectory, "slow-build");
|
||||
const reloadObserver = await startRequestObserver(context.port);
|
||||
let slowInstall: ReturnType<typeof context.paseo> | undefined;
|
||||
let slowReload: ReturnType<typeof context.paseo> | undefined;
|
||||
try {
|
||||
await mkdir(targetDirectory);
|
||||
await writeFile(
|
||||
path.join(targetDirectory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "slow-reload-target",
|
||||
requirements: { paseo: `>=${resolveCliVersion()}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(targetDirectory, "index.server.ts"),
|
||||
activationCountingPlugin(activationLog),
|
||||
);
|
||||
const targetInstall = await context.paseo(["plugin", "install", targetDirectory, "--json"]);
|
||||
assert.equal(targetInstall.exitCode, 0, targetInstall.stderr);
|
||||
assert.equal(await countActivations(activationLog), 1);
|
||||
|
||||
await mkdir(slowRepository);
|
||||
await git(slowRepository, ["init", "-b", "main"]);
|
||||
await git(slowRepository, ["config", "user.name", "Paseo Tests"]);
|
||||
await git(slowRepository, ["config", "user.email", "paseo@example.test"]);
|
||||
await writeFile(
|
||||
path.join(slowRepository, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "slow-build",
|
||||
requirements: { paseo: `>=${resolveCliVersion()}` },
|
||||
build: [[process.execPath, "-e", gateWaitBuild, buildStarted, buildGate]],
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(slowRepository, "index.server.ts"), pluginSource);
|
||||
await git(slowRepository, ["add", "-A"]);
|
||||
await git(slowRepository, ["commit", "-m", "blocking build"]);
|
||||
|
||||
// This install holds the plugin lifecycle queue while its build waits for the gate.
|
||||
slowInstall = context.paseo(
|
||||
["plugin", "add", `git:${pathToFileURL(slowRepository).href}`, "--json"],
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
// A failed launch must not arrive as an unhandled rejection: that ends this direct Node run
|
||||
// before the finally block below releases the build gate. Its result is still asserted.
|
||||
void slowInstall.catch(() => undefined);
|
||||
await waitFor(
|
||||
async () =>
|
||||
readFile(buildStarted, "utf8").then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
"the blocking build to start",
|
||||
);
|
||||
|
||||
slowReload = context.paseo(
|
||||
[
|
||||
"plugin",
|
||||
"reload",
|
||||
"slow-reload-target",
|
||||
"--host",
|
||||
`127.0.0.1:${reloadObserver.port}`,
|
||||
"--json",
|
||||
],
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
// Same as the install above: a launch failure must not end the run before the cleanup.
|
||||
void slowReload.catch(() => undefined);
|
||||
const reloadRequestedAt = await reloadObserver.waitForRequest("plugin.reload.request");
|
||||
|
||||
// The interim listing is not the reload's outcome: it still reports the plugin running and
|
||||
// no restart has happened while the reload waits behind the queue.
|
||||
const duringReload = await context.paseo(["plugin", "ls", "slow-reload-target", "--json"]);
|
||||
assert.equal(duringReload.exitCode, 0, duringReload.stderr);
|
||||
assert.equal(JSON.parse(duringReload.stdout)[0].status, "running");
|
||||
assert.equal(await countActivations(activationLog), 1);
|
||||
|
||||
const heldMs = Date.now() - reloadRequestedAt;
|
||||
if (heldMs < 66_000) {
|
||||
await sleep(66_000 - heldMs);
|
||||
}
|
||||
await writeFile(buildGate, "go");
|
||||
|
||||
const [buildResult, reloadResult] = await Promise.all([slowInstall, slowReload]);
|
||||
assert.equal(buildResult.exitCode, 0, buildResult.stderr);
|
||||
assert.equal(reloadResult.exitCode, 0, reloadResult.stderr);
|
||||
assert.equal(JSON.parse(reloadResult.stdout).status, "running");
|
||||
assert.equal(await countActivations(activationLog), 2);
|
||||
|
||||
// A plugin that cannot start still fails its reload with the daemon's error.
|
||||
const brokenDirectory = path.join(gateDirectory, "broken-plugin");
|
||||
await mkdir(brokenDirectory);
|
||||
await writeFile(
|
||||
path.join(brokenDirectory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "broken-reload",
|
||||
requirements: { paseo: `>=${resolveCliVersion()}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(brokenDirectory, "index.server.ts"), "export default broken !!!");
|
||||
const brokenInstall = await context.paseo(["plugin", "install", brokenDirectory, "--json"]);
|
||||
assert.equal(brokenInstall.exitCode, 1);
|
||||
assert.match(brokenInstall.stderr, /broken-reload|broken|failed/i);
|
||||
const brokenReload = await context.paseo(["plugin", "reload", "broken-reload", "--json"], {
|
||||
timeout: 60_000,
|
||||
});
|
||||
assert.equal(brokenReload.exitCode, 1);
|
||||
assert.match(brokenReload.stderr, /broken-reload|broken|failed/i);
|
||||
|
||||
for (const pluginId of ["slow-reload-target", "slow-build", "broken-reload"]) {
|
||||
const removed = await context.paseo(["plugin", "remove", pluginId, "--json"]);
|
||||
assert.equal(removed.exitCode, 0, removed.stderr);
|
||||
}
|
||||
} finally {
|
||||
// Release the gate, then let the install and the reload settle before their files go: the
|
||||
// build polls for the gate file, so cleanup must not delete it while the child still waits.
|
||||
await writeFile(buildGate, "go").catch(() => undefined);
|
||||
await Promise.all([slowInstall?.catch(() => undefined), slowReload?.catch(() => undefined)]);
|
||||
await reloadObserver.close();
|
||||
await rm(gateDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const remove = await context.paseo(["plugin", "remove", "cli-e2e", "--json"]);
|
||||
assert.equal(remove.exitCode, 0, remove.stderr);
|
||||
const removeGit = await context.paseo(["plugin", "remove", "git-cli-e2e", "--json"]);
|
||||
|
||||
@@ -1963,6 +1963,200 @@ test("gets a structured plugin log snapshot", async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps a plugin reload pending past the session RPC deadline", async () => {
|
||||
useHeartbeatClock();
|
||||
try {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const reload = client.reloadPlugin("example");
|
||||
let settled: "pending" | "resolved" | "rejected" = "pending";
|
||||
void reload.then(
|
||||
() => {
|
||||
settled = "resolved";
|
||||
return null;
|
||||
},
|
||||
() => {
|
||||
settled = "rejected";
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toEqual({
|
||||
type: "plugin.reload.request",
|
||||
requestId: expect.any(String),
|
||||
pluginId: "example",
|
||||
});
|
||||
|
||||
// A daemon that reloads for almost two minutes is still reloading, not a failed request.
|
||||
await vi.advanceTimersByTimeAsync(114_034);
|
||||
expect(settled).toBe("pending");
|
||||
|
||||
// Another plugin's reload finishing early must not settle this request.
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "plugin.reload.response",
|
||||
payload: {
|
||||
requestId: "req-other-plugin",
|
||||
plugin: { id: "other", path: "/plugins/other", enabled: true, status: "running" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(settled).toBe("pending");
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "plugin.reload.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
plugin: { id: "example", path: "/plugins/example", enabled: true, status: "running" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(reload).resolves.toEqual({
|
||||
id: "example",
|
||||
path: "/plugins/example",
|
||||
enabled: true,
|
||||
status: "running",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps enable and disable inside the session RPC deadline", async () => {
|
||||
useHeartbeatClock();
|
||||
try {
|
||||
for (const action of ["enable", "disable"] as const) {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const pending =
|
||||
action === "enable" ? client.enablePlugin("example") : client.disablePlugin("example");
|
||||
let settled = false;
|
||||
void pending.then(
|
||||
() => {
|
||||
settled = true;
|
||||
return null;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: `plugin.${action}.request`,
|
||||
requestId: expect.any(String),
|
||||
pluginId: "example",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(59_999);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(pending).rejects.toThrow("Timeout waiting for message (60000ms)");
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("reports a plugin reload failure that arrives after the session RPC deadline", async () => {
|
||||
useHeartbeatClock();
|
||||
try {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const reload = client.reloadPlugin("example");
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(114_034);
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
requestType: "plugin.reload.request",
|
||||
error: "Request failed: Plugin failed to start: example",
|
||||
code: "handler_error",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(reload).rejects.toThrow(
|
||||
"Request failed: Plugin failed to start: example requestType=plugin.reload.request code=handler_error",
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a pending plugin reload when the connection closes", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const reload = client.reloadPlugin("example");
|
||||
expect(parseSentFrame(mock.sent[0])).toMatchObject({ type: "plugin.reload.request" });
|
||||
|
||||
mock.triggerClose({ code: 1006, reason: "network lost" });
|
||||
|
||||
await expect(reload).rejects.toThrow(/network lost|disconnected|closed/i);
|
||||
|
||||
// Reconnecting must not resend the lost reload: only the caller decides to reload again.
|
||||
const reconnecting = client.connect();
|
||||
mock.triggerOpen();
|
||||
await reconnecting;
|
||||
expect(mock.sent).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => {
|
||||
useHeartbeatClock();
|
||||
const logger = createMockLogger();
|
||||
|
||||
@@ -5692,6 +5692,10 @@ export class DaemonClient {
|
||||
requestId,
|
||||
message: { type: `plugin.${action}.request`, requestId, pluginId },
|
||||
responseType: `plugin.${action}.response`,
|
||||
// Reload tears the plugin down and starts it again before the daemon answers, and it queues
|
||||
// behind other plugin lifecycle work. Wait for that answer instead of failing the reload at
|
||||
// the request deadline while the daemon keeps loading it.
|
||||
...(action === "reload" ? { timeout: 0 } : {}),
|
||||
});
|
||||
return payload.plugin;
|
||||
}
|
||||
|
||||
@@ -575,6 +575,36 @@ describe("ClaudeAgentClient binary resolution", () => {
|
||||
await expect(resolveClaudeCodeVersion()).resolves.toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
test("rejects a wrapper command's own version output instead of reading it as Claude Code's", async () => {
|
||||
const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-version-"));
|
||||
const wrapperPath = path.join(wrapperDir, "wrapper");
|
||||
await fs.writeFile(wrapperPath, 'console.log("cux 0.4.1");\n');
|
||||
try {
|
||||
await expect(
|
||||
resolveClaudeCodeVersion({
|
||||
command: { mode: "replace", argv: [process.execPath, wrapperPath] },
|
||||
}),
|
||||
).rejects.toThrow("Unable to parse Claude Code version from --version output");
|
||||
} finally {
|
||||
await fs.rm(wrapperDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves the version when the command forwards the Claude Code banner", async () => {
|
||||
const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-version-"));
|
||||
const wrapperPath = path.join(wrapperDir, "wrapper");
|
||||
await fs.writeFile(wrapperPath, 'console.log("2.1.302 (Claude Code)");\n');
|
||||
try {
|
||||
await expect(
|
||||
resolveClaudeCodeVersion({
|
||||
command: { mode: "replace", argv: [process.execPath, wrapperPath] },
|
||||
}),
|
||||
).resolves.toBe("2.1.302");
|
||||
} finally {
|
||||
await fs.rm(wrapperDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("loads user, project, and local Claude settings", async () => {
|
||||
const queryReturn = vi.fn();
|
||||
queryReturn.mockResolvedValue(undefined);
|
||||
|
||||
@@ -1839,7 +1839,13 @@ export async function resolveClaudeCodeVersion(
|
||||
timeout: 5_000,
|
||||
signal,
|
||||
});
|
||||
const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`);
|
||||
// A wrapper command (the `command` provider setting) prints its own version here
|
||||
// instead of Claude Code's. Only trust output that identifies itself as Claude Code,
|
||||
// so a wrapper's version can't mis-gate models behind a minimumClaudeCodeVersion.
|
||||
const rawVersionOutput = `${stdout}\n${stderr}`;
|
||||
const version = /\d+\.\d+\.\d+\s+\(Claude Code\)/i.test(rawVersionOutput)
|
||||
? parseClaudeCodeVersion(rawVersionOutput)
|
||||
: null;
|
||||
if (!version) {
|
||||
throw new Error("Unable to parse Claude Code version from --version output");
|
||||
}
|
||||
|
||||
@@ -559,6 +559,33 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
expect(models.map((model) => model.id)).not.toContain("claude-opus-5[1m]");
|
||||
expect(models.map((model) => model.id)).not.toContain("claude-opus-5");
|
||||
});
|
||||
|
||||
it("keeps version-gated models when the probe command cannot identify Claude Code", async () => {
|
||||
const configDir = await createClaudeConfigDir({});
|
||||
const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-wrapper-"));
|
||||
const wrapperPath = path.join(wrapperDir, "wrapper");
|
||||
await fs.writeFile(wrapperPath, 'console.log("cux 0.4.1");\n');
|
||||
try {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtimeSettings: {
|
||||
command: { mode: "replace", argv: [process.execPath, wrapperPath] },
|
||||
env: { CLAUDE_CONFIG_DIR: configDir },
|
||||
},
|
||||
discoverModels: async () => [],
|
||||
});
|
||||
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: configDir,
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(models.map((model) => model.id)).toContain("claude-opus-5-5");
|
||||
} finally {
|
||||
await fs.rm(wrapperDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeClaudeRuntimeModelId", () => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentSession } from "../agent-sdk-types.js";
|
||||
import { KimiACPAgentClient } from "./kimi-acp-agent.js";
|
||||
|
||||
// Diagnostic for #7128. Run explicitly with an installed Kimi Code executable.
|
||||
// This uses real Kimi ACP, but local model metadata rather than its managed catalog.
|
||||
// No prompt or inference request is sent. It does not reproduce the reporter's load failure.
|
||||
const command = process.env.KIMI_CODE_COMMAND;
|
||||
const run = command ? describe : describe.skip;
|
||||
|
||||
run("Kimi advertised thinking contract (#7128 diagnostic)", () => {
|
||||
let root: string;
|
||||
let cwd: string;
|
||||
let client: KimiACPAgentClient;
|
||||
const sessions: AgentSession[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "paseo-kimi-thinking-"));
|
||||
cwd = join(root, "project");
|
||||
const home = join(root, "kimi");
|
||||
await mkdir(cwd);
|
||||
await mkdir(home);
|
||||
await writeFile(
|
||||
join(home, "config.toml"),
|
||||
`default_model = "highspeed"
|
||||
telemetry = false
|
||||
[thinking]
|
||||
enabled = true
|
||||
effort = "high"
|
||||
[providers.fixture]
|
||||
type = "anthropic"
|
||||
base_url = "http://127.0.0.1:1"
|
||||
api_key = "local-config-only-no-inference"
|
||||
[models.highspeed]
|
||||
provider = "fixture"
|
||||
model = "kimi-for-coding-highspeed"
|
||||
max_context_size = 262144
|
||||
capabilities = ["thinking", "always_thinking"]
|
||||
[models.k3]
|
||||
provider = "fixture"
|
||||
model = "k3"
|
||||
max_context_size = 262144
|
||||
capabilities = ["thinking", "always_thinking"]
|
||||
support_efforts = ["low", "high", "max"]
|
||||
default_effort = "high"
|
||||
`,
|
||||
);
|
||||
client = new KimiACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command: [command!, "acp"],
|
||||
env: { KIMI_CODE_HOME: home },
|
||||
providerId: "kimi",
|
||||
label: "Kimi Code CLI",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions.splice(0)) await session.close();
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function create(model = "highspeed"): Promise<AgentSession> {
|
||||
const session = await client.createSession({
|
||||
provider: "acp",
|
||||
cwd,
|
||||
model,
|
||||
modeId: "yolo",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
test("an advertised High option is selectable on the boolean-thinking model", async () => {
|
||||
const catalog = await client.fetchCatalog({ scope: "workspace", cwd, force: true });
|
||||
expect(catalog.models.find((model) => model.id === "highspeed")?.thinkingOptions).toEqual([
|
||||
expect.objectContaining({ id: "on" }),
|
||||
expect.objectContaining({ id: "high" }),
|
||||
]);
|
||||
const session = await create();
|
||||
await expect(session.setThinkingOption!("high")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("control: the boolean model accepts On", async () => {
|
||||
const session = await create();
|
||||
await session.setThinkingOption!("on");
|
||||
expect((await session.getRuntimeInfo()).thinkingOptionId).toBe("on");
|
||||
});
|
||||
|
||||
test("control: the effort-level model accepts High", async () => {
|
||||
const session = await create("k3");
|
||||
await session.setThinkingOption!("high");
|
||||
expect((await session.getRuntimeInfo()).thinkingOptionId).toBe("high");
|
||||
});
|
||||
|
||||
test("control: an unchanged session reopens with High and can be changed to On", async () => {
|
||||
const session = await create();
|
||||
const handle = session.describePersistence();
|
||||
expect(handle).not.toBeNull();
|
||||
await session.close();
|
||||
sessions.splice(sessions.indexOf(session), 1);
|
||||
const resumed = await client.resumeSession(handle!);
|
||||
sessions.push(resumed);
|
||||
expect((await resumed.getRuntimeInfo()).thinkingOptionId).toBe("high");
|
||||
await resumed.setThinkingOption!("on");
|
||||
expect((await resumed.getRuntimeInfo()).thinkingOptionId).toBe("on");
|
||||
});
|
||||
|
||||
test("adapted: a saved High handle reopens after the provider session changes to On", async () => {
|
||||
const session = await create();
|
||||
const savedHandle = session.describePersistence();
|
||||
expect(savedHandle).not.toBeNull();
|
||||
// Deliberately create disagreement between Paseo's saved handle and Kimi's session.
|
||||
// The report does not establish how that disagreement arose on the affected host.
|
||||
await session.setThinkingOption!("on");
|
||||
await session.close();
|
||||
sessions.splice(sessions.indexOf(session), 1);
|
||||
const resumed = await client.resumeSession(savedHandle!);
|
||||
sessions.push(resumed);
|
||||
expect((await resumed.getRuntimeInfo()).thinkingOptionId).toBe("high");
|
||||
});
|
||||
});
|
||||
@@ -244,6 +244,13 @@ paseo plugin enable my-plugin
|
||||
paseo plugin remove my-plugin
|
||||
```
|
||||
|
||||
`paseo plugin reload <id>` reports the result of the reload itself: it waits while the daemon stops
|
||||
and starts the plugin, however long that takes, and it queues behind the daemon's other plugin work.
|
||||
Cancelling the command or losing the connection does not stop the daemon's work, and no listing or
|
||||
log entry settles what a cancelled reload did — the [Plugin reference](/docs/plugins/reference)
|
||||
covers those interim states. Running the reload again is what reports the result: it waits behind the
|
||||
outstanding one, then stops and starts the plugin once more.
|
||||
|
||||
Bare `owner/slug` installs a reviewed registry listing. Use `git:owner/repository` for GitHub
|
||||
source, or `./plugin` for a local directory. Append `:<directory>` for a plugin in a monorepo.
|
||||
|
||||
|
||||
@@ -2269,6 +2269,12 @@ remove Command Center items and clear the installation's query state. An already
|
||||
remains as unavailable until its matching contribution returns or the user closes it. Panel render
|
||||
failures stay inside the plugin error boundary.
|
||||
|
||||
Reload queues behind the daemon's other plugin work, such as a running installation or preparation
|
||||
command, and the daemon answers when the plugin is running again or has failed. Nothing reads the
|
||||
plugin's status as the reload's outcome: while the reload is queued, `plugin ls`, Settings, and the
|
||||
logs show the plugin's previous state, and while the daemon stops and starts it, a status read
|
||||
reports `failed` because the plugin is absent from the runtime catalog until its start completes.
|
||||
|
||||
## Plugin sources
|
||||
|
||||
Paste one of these source identifiers into **Settings → Plugins**, or pass it to
|
||||
|
||||
Reference in New Issue
Block a user