Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 0c1b4e7898 test(app): reproduce settings sidebar scroll reset on section select (#1532)
Selecting a settings section mounts a new settings screen, so the
desktop sidebar's scroll body starts again at offset 0. This test
scrolls the sidebar to its end and selects two host sections; it fails
on main with the offset dropping from 236 to 0.
2026-10-04 09:36:03 +02:00
paseo-bot[bot]and3ae3ae 8216e86ecb fix(cli): delete archived agents with agent delete --all and --cwd (#6027)
* fix(cli): delete archived agents in bulk commands

* test(cli): cover archived agents in bulk delete from the CLI suite

The daemon-backed test imported server test utilities from a CLI test,
which the CI contract in scripts/ci-workflow.test.mjs forbids. Cover
--all and --cwd against archived agents in the runDeleteCommand unit
test instead.

* fix(cli): read every history page when bulk-deleting agents

agent delete --all and --cwd read one page of fetch_agents, so agents
past the first 200 were skipped, and fetch_agents leaves out saved
agents whose provider is unavailable. List agents from the paged agent
history, which the History view uses, so bulk delete covers both.

* fix(cli): delete an agent by ID without listing history

---------

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 07:55:21 +02:00
paseo-bot[bot] 79a3942393 fix(server): mark Claude terminals as needing input when Claude goes idle (#6022)
Claude Code reports the kind of notification in `notification_type`, but
the hook parser only read `matcher` and `reason`, which Claude never sends
in Notification input. Every idle_prompt notification resolved to nothing,
so a Claude terminal waiting for the user never showed as needing input.

Closes #5845

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 06:10:19 +02:00
7 changed files with 138 additions and 20 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ Claude hook mapping:
- `UserPromptSubmit` → `running`
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input`
- `Notification` with `notification_type` equal to `idle_prompt` → `needs-input`
Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input
while terminal activity is working clears the activity without finished attention. The same
@@ -40,6 +40,8 @@ import {
expectSettingsHostPickerLabel,
openSettingsHostSection,
removeCurrentHostFromSettings,
scrollSettingsSidebarToEnd,
expectSettingsSidebarScrollOffset,
} from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { expectAppRoute } from "../support/helpers/route-assertions";
@@ -77,6 +79,23 @@ test.describe("Settings sidebar navigation", () => {
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test.describe("in a short window", () => {
test.use({ viewport: { width: 1280, height: 480 } });
test("selecting a section keeps the sidebar scroll position", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
const offset = await scrollSettingsSidebarToEnd(page);
await openSettingsHostSection(page, serverId, "plugins");
await expectSettingsSidebarScrollOffset(page, offset);
await openSettingsHostSection(page, serverId, "terminals");
await expectSettingsSidebarScrollOffset(page, offset);
});
});
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
await gotoAppShell(page);
await verifyLegacyHostSettingsRedirect(page);
@@ -453,3 +453,21 @@ export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
}
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
const scrollBody = page.getByTestId("settings-sidebar-scroll-body");
await expect(scrollBody).toBeVisible();
await scrollBody.hover();
await page.mouse.wheel(0, 10_000);
const readOffset = () => scrollBody.evaluate((node) => node.scrollTop);
await expect
.poll(readOffset, { message: "settings sidebar must overflow for this check" })
.toBeGreaterThan(0);
await expect.poll(async () => (await readOffset()) - (await readOffset())).toBe(0);
return readOffset();
}
export async function expectSettingsSidebarScrollOffset(page: Page, offset: number): Promise<void> {
const scrollBody = page.getByTestId("settings-sidebar-scroll-body");
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
}
+63 -3
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
@@ -7,18 +7,45 @@ import { runDeleteCommand } from "./delete.js";
const agent = {
id: "11111111-1111-4111-8111-111111111111",
status: "running",
archivedAt: null,
archivedAt: null as string | null,
cwd: "/tmp/project",
};
const archivedAgent = {
id: "22222222-2222-4222-8222-222222222222",
status: "closed",
archivedAt: "2026-10-01T00:00:00.000Z",
cwd: "/tmp/project/nested",
};
const archivedOutsideAgent = {
id: "33333333-3333-4333-8333-333333333333",
status: "closed",
archivedAt: "2026-10-01T00:00:00.000Z",
cwd: "/tmp/elsewhere",
};
let daemonAgents: (typeof agent)[] = [agent];
const toEntry = (entry: typeof agent) => ({ agent: entry });
// The fake daemon pages history two agents at a time.
const historyPage = (cursor: string | undefined) => {
const start = Number(cursor ?? 0);
const end = start + 2;
return {
entries: daemonAgents.slice(start, end).map(toEntry),
pageInfo: { nextCursor: end < daemonAgents.length ? String(end) : null },
};
};
const cancelAgent = vi.fn(async () => {
throw new Error("active run cancellation was not acknowledged");
});
const fetchAgentHistory = vi.fn(async (options: { page: { cursor?: string } }) =>
historyPage(options.page.cursor),
);
const deleteAgent = vi.fn(async () => undefined);
const close = vi.fn(async () => undefined);
vi.mock("../../utils/client.js", () => ({
connectToDaemon: vi.fn(async () => ({
fetchAgents: vi.fn(async () => ({ entries: [{ agent }] })),
fetchAgents: vi.fn(async () => historyPage(undefined)),
fetchAgentHistory,
fetchAgent: vi.fn(async () => ({ agent })),
cancelAgent,
deleteAgent,
@@ -28,9 +55,16 @@ vi.mock("../../utils/client.js", () => ({
}));
describe("runDeleteCommand", () => {
beforeEach(() => {
daemonAgents = [agent];
deleteAgent.mockClear();
fetchAgentHistory.mockClear();
});
it("force-deletes a running agent when graceful cancellation is refused", async () => {
const result = await runDeleteCommand(agent.id, { daemonTarget }, {} as never);
expect(fetchAgentHistory).not.toHaveBeenCalled();
expect(cancelAgent).toHaveBeenCalledWith(agent.id);
expect(deleteAgent).toHaveBeenCalledWith(agent.id);
expect(result.data).toEqual({
@@ -38,4 +72,30 @@ describe("runDeleteCommand", () => {
agentIds: [agent.id],
});
});
it("deletes archived agents on every history page with --all", async () => {
daemonAgents = [agent, archivedAgent, archivedOutsideAgent];
const result = await runDeleteCommand(undefined, { daemonTarget, all: true }, {} as never);
expect(result.data).toEqual({
deletedCount: 3,
agentIds: [agent.id, archivedAgent.id, archivedOutsideAgent.id],
});
});
it("deletes archived agents inside the directory with --cwd", async () => {
daemonAgents = [agent, archivedAgent, archivedOutsideAgent];
const result = await runDeleteCommand(
undefined,
{ daemonTarget, cwd: "/tmp/project" },
{} as never,
);
expect(result.data).toEqual({
deletedCount: 2,
agentIds: [agent.id, archivedAgent.id],
});
});
});
+23 -11
View File
@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { connectToDaemon } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
import type { DaemonClient, FetchAgentHistoryEntry } from "@getpaseo/client/internal/daemon-client";
export function addDeleteOptions(cmd: Command): Command {
return cmd
@@ -33,6 +34,20 @@ export interface AgentDeleteOptions extends CommandOptions {
export type AgentDeleteResult = SingleResult<DeleteResult>;
// History includes archived agents and agents whose provider is unavailable.
async function fetchHistoryAgents(client: DaemonClient) {
const agents: FetchAgentHistoryEntry["agent"][] = [];
let cursor: string | undefined;
do {
const payload = await client.fetchAgentHistory({
page: { limit: 200, ...(cursor ? { cursor } : {}) },
});
agents.push(...payload.entries.map((entry) => entry.agent));
cursor = payload.pageInfo.nextCursor ?? undefined;
} while (cursor);
return agents;
}
export async function runDeleteCommand(
id: string | undefined,
options: AgentDeleteOptions,
@@ -50,19 +65,16 @@ export async function runDeleteCommand(
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
let agents = fetchPayload.entries.map((entry) => entry.agent);
const deletedIds: string[] = [];
let agents: FetchAgentHistoryEntry["agent"][];
if (options.all) {
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
agents = agents.filter((a) => {
if (a.archivedAt) return false;
return isSameOrDescendantPath(options.cwd!, a.cwd);
});
} else if (id) {
const fetchResult = await client.fetchAgent({ agentId: id });
if (options.all || options.cwd) {
agents = await fetchHistoryAgents(client);
if (!options.all) {
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
}
} else {
const fetchResult = await client.fetchAgent({ agentId: id! });
if (!fetchResult) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",
+12 -3
View File
@@ -17,6 +17,15 @@ function inputFrom(value: string) {
};
}
function claudeNotification(notificationType: string) {
return JSON.stringify({
session_id: "session-1",
hook_event_name: "Notification",
message: "Claude is waiting for your input",
notification_type: notificationType,
});
}
function ttyInput() {
return {
isTTY: true,
@@ -123,7 +132,7 @@ describe("runHooksCommand", () => {
const send = await runHook(
claudeProvider.id,
claudeProvider.events[4].event,
inputFrom('{"reason":"idle_prompt"}'),
inputFrom(claudeNotification("idle_prompt")),
);
expectPostedState(send, "needs-input");
@@ -131,11 +140,11 @@ describe("runHooksCommand", () => {
it.each(["permission_prompt", "elicitation_prompt", "elicitation_response", "auth_success"])(
"ignores Claude %s notifications",
async (reason) => {
async (notificationType) => {
const send = await runHook(
claudeProvider.id,
claudeProvider.events[4].event,
inputFrom(JSON.stringify({ reason })),
inputFrom(claudeNotification(notificationType)),
);
expect(send.calls).toEqual([]);
@@ -40,8 +40,8 @@ function isIdlePrompt(raw: string | null): boolean {
try {
const notification = JSON.parse(raw) as unknown;
if (!notification || typeof notification !== "object") return false;
const payload = notification as { matcher?: unknown; reason?: unknown };
return payload.matcher === "idle_prompt" || payload.reason === "idle_prompt";
const payload = notification as { notification_type?: unknown };
return payload.notification_type === "idle_prompt";
} catch {
return false;
}