Default client RPC waits to 60 seconds (#1789)

* fix(client): wait longer for session responses

* fix(client): default RPC waits to sixty seconds

* fix(app): preserve detached stream scroll on delayed history

Code drift: longer client RPC waits let delayed timeline responses arrive after a user scroll-away, so web stream anchoring must not reattach on transient scroll-top resets.

Restore the 15s connect deadline and leave app initialization slack above the default 60s session RPC wait.

* fix(client): keep helper waits within caller deadlines

Review fix: the 60s default session RPC wait leaked into wait previews and waitForAgentUpsert helper fetches. Bound those helper RPCs to the caller deadline or a short best-effort preview timeout, and allow small scroll ranges to reattach at bottom.

* fix(client): respect caller timeout budgets

* fix(cli): keep diagnostic probes responsive

* Refactor daemon client request options

* Preserve daemon client legacy overloads
This commit is contained in:
Mohamed Boudra
2026-06-29 17:23:18 +02:00
committed by GitHub
parent 57800a0f17
commit b613bea9f6
58 changed files with 1234 additions and 281 deletions
+4 -2
View File
@@ -88,11 +88,13 @@ export async function archiveAgentFromDaemon(
export async function fetchAgentArchivedAt(
client: {
fetchAgent(agentId: string): Promise<{ agent: { archivedAt?: string | null } } | null>;
fetchAgent(options: {
agentId: string;
}): Promise<{ agent: { archivedAt?: string | null } } | null>;
},
agentId: string,
): Promise<string | null> {
const result = await client.fetchAgent(agentId);
const result = await client.fetchAgent({ agentId });
return result?.agent.archivedAt ?? null;
}
+3 -3
View File
@@ -132,9 +132,9 @@ export interface SeedDaemonClient {
timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
fetchAgent(
agentId: string,
): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
fetchAgent(options: {
agentId: string;
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
getLastServerInfoMessage(): {
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
} | null;
@@ -404,4 +404,211 @@ describe("createWebStreamStrategy", () => {
expect(scrollTo).not.toHaveBeenCalled();
});
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
const scrollTo = vi.fn(function (
this: HTMLElement,
options?: ScrollToOptions | number,
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", {
configurable: true,
value: top,
});
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
const routeBottomAnchorRequest = {
agentId: "agent",
reason: "initial-entry" as const,
requestKey: "server:agent:initial-entry",
};
const renderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted,
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
...renderInput,
isAuthoritativeHistoryReady: false,
}),
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 991 });
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 }));
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 91 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2531 });
act(() => {
root?.render(
strategy.render({
...renderInput,
isAuthoritativeHistoryReady: true,
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
});
it("reattaches follow-output when a small scroll range returns to bottom", async () => {
const scrollTo = vi.fn(function (
this: HTMLElement,
options?: ScrollToOptions | number,
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", {
configurable: true,
value: top,
});
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const renderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
...renderInput,
isAuthoritativeHistoryReady: true,
}),
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 }));
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 20 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: {
...renderInput.segments,
liveHead: [userMessage(3)],
},
isAuthoritativeHistoryReady: true,
}),
);
});
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(scrollTo).toHaveBeenCalled();
});
});
@@ -117,11 +117,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
contentRef.current = node;
}, []);
const [followOutput, setFollowOutputr] = useState(true);
const followOutputRef = useRef(followOutput);
const setFollowOutput = (value: boolean) => {
followOutputRef.current = value;
setFollowOutputr(value);
return value;
};
const followOutputRef = useRef(followOutput);
const lastKnownScrollTopRef = useRef(0);
const pendingUserScrollUpIntentRef = useRef(false);
const isPointerScrollActiveRef = useRef(false);
@@ -277,12 +278,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const currentScrollTop = scrollContainer.scrollTop;
const isAtBottom = isScrollContainerAtBottom(scrollContainer);
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
const scrolledDown =
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
if (!followOutputRef.current && isAtBottom) {
if (!followOutputRef.current && isAtBottom && scrolledDown) {
setFollowOutput(true);
pendingUserScrollUpIntentRef.current = false;
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
if (scrolledUp) {
if (scrolledUp || !isAtBottom) {
cancelPendingStickToBottom();
setFollowOutput(false);
}
@@ -84,7 +84,7 @@ function HostAgentReadyRouteContent() {
let cancelled = false;
void client
.fetchAgent(agentId)
.fetchAgent({ agentId })
.then((result) => {
if (cancelled || redirectedRef.current) {
return;
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { refreshAgentInitializationTimeout } from "@/hooks/use-agent-initialization";
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
@@ -1210,6 +1211,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
error: payload.error,
});
if (followUp?.direction === "after") {
refreshAgentInitializationTimeout({
key: initKey,
agentId,
setAgentInitializing: (id, initializing) => {
if (initializing) {
return;
}
clearAgentInitializingFlag(setInitializingAgents, serverId, id);
},
});
requestCanonicalCatchUp(agentId, {
epoch: followUp.cursor.epoch,
endSeq: followUp.cursor.seq,
@@ -1,12 +1,11 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
type AgentCommandsClient,
type DraftCommandConfig,
fetchAgentCommands,
} from "./use-agent-commands-query";
type ListCommands = DaemonClient["listCommands"];
type ListCommands = AgentCommandsClient["listCommands"];
type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
interface ListCommandsCall {
@@ -22,15 +21,8 @@ function createClient(response: ListCommandsResult): FakeAgentCommandsClient {
const calls: ListCommandsCall[] = [];
return {
calls,
listCommands: (async (
agentId: string,
requestIdOrOptions?: string | { draftConfig?: DraftCommandConfig },
) => {
const options =
typeof requestIdOrOptions === "object" && requestIdOrOptions !== null
? requestIdOrOptions
: undefined;
calls.push({ agentId, draftConfig: options?.draftConfig });
listCommands: (async (options: Parameters<ListCommands>[0]) => {
calls.push({ agentId: options.agentId, draftConfig: options.draftConfig });
return response;
}) as ListCommands,
};
@@ -16,14 +16,22 @@ export interface AgentSlashCommand {
export type DraftCommandConfig = AgentCommandsDraftConfig;
export type AgentCommandsClient = Pick<DaemonClient, "listCommands">;
interface ListAgentCommandsOptions {
agentId: string;
draftConfig?: DraftCommandConfig;
}
export interface AgentCommandsClient {
listCommands(options: ListAgentCommandsOptions): ReturnType<DaemonClient["listCommands"]>;
}
export async function fetchAgentCommands(input: {
client: AgentCommandsClient;
agentId: string;
draftConfig?: DraftCommandConfig;
}): Promise<AgentSlashCommand[]> {
const response = await input.client.listCommands(input.agentId, {
const response = await input.client.listCommands({
agentId: input.agentId,
draftConfig: input.draftConfig,
});
return response.commands as AgentSlashCommand[];
@@ -5,6 +5,7 @@ import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-
import {
createSetAgentInitializing,
ensureAgentIsInitialized,
refreshAgentInitializationTimeout,
refreshAgent,
} from "./use-agent-initialization";
@@ -80,7 +81,7 @@ describe("ensureAgentIsInitialized", () => {
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
});
it("times out initialization after 30 seconds", async () => {
it("times out initialization after 65 seconds", async () => {
vi.useFakeTimers();
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -92,18 +93,50 @@ describe("ensureAgentIsInitialized", () => {
setAgentInitializing: bindSetAgentInitializing(),
});
vi.advanceTimersByTime(29_999);
vi.advanceTimersByTime(64_999);
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeDefined();
vi.advanceTimersByTime(1);
await expect(promise).rejects.toThrow("History sync timed out after 30s");
await expect(promise).rejects.toThrow("History sync timed out after 65s");
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeUndefined();
expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get(agentId)).toBe(
false,
);
vi.useRealTimers();
});
it("refreshes the initialization timeout after paged catch-up progress", async () => {
vi.useFakeTimers();
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
const setAgentInitializing = bindSetAgentInitializing();
const key = getInitKey(serverId, agentId);
const promise = ensureAgentIsInitialized({
serverId,
agentId,
client: client as never,
setAgentInitializing,
});
vi.advanceTimersByTime(64_999);
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
vi.advanceTimersByTime(1);
expect(getInitDeferred(key)).toBeDefined();
const rejection = expect(promise).rejects.toThrow("History sync timed out after 65s");
vi.advanceTimersByTime(64_998);
expect(getInitDeferred(key)).toBeDefined();
vi.advanceTimersByTime(1);
await rejection;
expect(getInitDeferred(key)).toBeUndefined();
vi.useRealTimers();
});
});
describe("refreshAgent", () => {
@@ -3,19 +3,36 @@ import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import {
attachInitTimeout,
createInitDeferred,
getInitDeferred,
getInitKey,
INIT_TIMEOUT_MS,
rejectInitDeferred,
refreshInitTimeout,
} from "@/utils/agent-initialization";
import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { i18n } from "@/i18n/i18next";
export const INIT_TIMEOUT_MS = 30_000;
export type SetAgentInitializing = (agentId: string, initializing: boolean) => void;
export function createHistorySyncTimeoutError(): Error {
return new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`);
}
export function refreshAgentInitializationTimeout(input: {
key: string;
agentId: string;
setAgentInitializing: SetAgentInitializing;
}): void {
refreshInitTimeout({
key: input.key,
onTimeout: () => {
input.setAgentInitializing(input.agentId, false);
rejectInitDeferred(input.key, createHistorySyncTimeoutError());
},
});
}
export interface EnsureAgentIsInitializedInput {
serverId: string;
agentId: string;
@@ -38,14 +55,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
const timelineRequest = planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory });
const deferred = createInitDeferred(key, timelineRequest.direction);
const timeoutId = setTimeout(() => {
setAgentInitializing(agentId, false);
rejectInitDeferred(
key,
new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`),
);
}, INIT_TIMEOUT_MS);
attachInitTimeout(key, timeoutId);
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
setAgentInitializing(agentId, true);
+2 -2
View File
@@ -588,7 +588,7 @@ function AgentPanelBody({
const attemptToken = ++lookupAttemptTokenRef.current;
client
.fetchAgent(agentId)
.fetchAgent({ agentId })
.then((result) => {
if (attemptToken !== lookupAttemptTokenRef.current) {
return;
@@ -985,7 +985,7 @@ function ChatAgentContent({
const currentAgent =
currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId);
if (!currentAgent) {
const result = await client.fetchAgent(agentId);
const result = await client.fetchAgent({ agentId });
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
+11 -2
View File
@@ -7,6 +7,7 @@ export interface DeferredInit {
}
const initPromises = new Map<string, DeferredInit>();
export const INIT_TIMEOUT_MS = 65_000;
export function getInitKey(serverId: string, agentId: string): string {
return `${serverId}:${agentId}`;
@@ -36,12 +37,20 @@ export function createInitDeferred(key: string, requestDirection: "tail" | "afte
return deferred;
}
export function attachInitTimeout(key: string, timeoutId: ReturnType<typeof setTimeout>): void {
const deferred = initPromises.get(key);
export function refreshInitTimeout(input: {
key: string;
onTimeout: () => void;
timeoutMs?: number;
}): void {
const timeoutId = setTimeout(input.onTimeout, input.timeoutMs ?? INIT_TIMEOUT_MS);
const deferred = initPromises.get(input.key);
if (!deferred) {
clearTimeout(timeoutId);
return;
}
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
deferred.timeoutId = timeoutId;
}
+6 -2
View File
@@ -6,7 +6,10 @@ export function addAttachOptions(cmd: Command): Command {
.argument("<id>", "Agent ID (or prefix)");
}
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
import {
fetchProjectedTimelineItems,
LIVE_HISTORY_FETCH_TIMEOUT_MS,
} from "../../utils/timeline.js";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { AgentStreamEventPayload, AgentStreamMessage } from "@getpaseo/protocol/messages";
@@ -121,7 +124,7 @@ export async function runAttachCommand(
}
try {
const fetchResult = await client.fetchAgent(id);
const fetchResult = await client.fetchAgent({ agentId: id });
if (!fetchResult) {
console.error(`Error: No agent found matching: ${id}`);
console.error("Use `paseo ls` to list available agents");
@@ -139,6 +142,7 @@ export async function runAttachCommand(
const timelineItems = await fetchProjectedTimelineItems({
client,
agentId: resolvedId,
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
});
for (const item of timelineItems) {
printTimelineItem(item);
+1 -1
View File
@@ -76,7 +76,7 @@ export async function runDeleteCommand(
return isSameOrDescendantPath(options.cwd!, a.cwd);
});
} else if (id) {
const fetchResult = await client.fetchAgent(id);
const fetchResult = await client.fetchAgent({ agentId: id });
if (!fetchResult) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",
+1 -1
View File
@@ -243,7 +243,7 @@ export async function runInspectCommand(
}
try {
const fetchResult = await client.fetchAgent(agentIdArg);
const fetchResult = await client.fetchAgent({ agentId: agentIdArg });
if (!fetchResult) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",
+15 -4
View File
@@ -1,7 +1,10 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions } from "../../output/index.js";
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
import {
fetchProjectedTimelineItems,
LIVE_HISTORY_FETCH_TIMEOUT_MS,
} from "../../utils/timeline.js";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { AgentStreamMessage } from "@getpaseo/protocol/messages";
@@ -32,8 +35,9 @@ export const NO_ACTIVITY_MESSAGE = "No activity to display.";
export async function fetchAgentTimelineItems(
client: DaemonClient,
agentId: string,
options?: { timeoutMs?: number },
): Promise<AgentTimelineItem[]> {
return fetchProjectedTimelineItems({ client, agentId });
return fetchProjectedTimelineItems({ client, agentId, timeoutMs: options?.timeoutMs });
}
export function formatAgentActivityTranscript(
@@ -107,7 +111,7 @@ export async function runLogsCommand(
}
try {
const fetchResult = await client.fetchAgent(id);
const fetchResult = await client.fetchAgent({ agentId: id });
if (!fetchResult) {
console.error(`Error: No agent found matching: ${id}`);
console.error("Use `paseo ls` to list available agents");
@@ -173,7 +177,14 @@ async function runFollowMode(
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
// First, get existing timeline.
let existingItems = await fetchAgentTimelineItems(client, agentId);
let existingItems: AgentTimelineItem[] = [];
try {
existingItems = await fetchAgentTimelineItems(client, agentId, {
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
});
} catch (error) {
console.warn("Warning: failed to fetch existing timeline", error);
}
// Apply filter to existing items
if (options.filter) {
+1 -1
View File
@@ -65,7 +65,7 @@ export async function runModeCommand(
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
try {
client = await connectToDaemon({ host: options.host });
const fetchResult = await client.fetchAgent(id);
const fetchResult = await client.fetchAgent({ agentId: id });
if (!fetchResult) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",
+1 -1
View File
@@ -83,7 +83,7 @@ export async function runStopCommand(
});
} else if (id) {
// Stop specific agent
const fetchResult = await client.fetchAgent(id);
const fetchResult = await client.fetchAgent({ agentId: id });
if (!fetchResult) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",
+2 -2
View File
@@ -132,7 +132,7 @@ export async function runUpdateCommand(
}
try {
const fetchResult = await client.fetchAgent(agentIdArg);
const fetchResult = await client.fetchAgent({ agentId: agentIdArg });
if (!fetchResult) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",
@@ -148,7 +148,7 @@ export async function runUpdateCommand(
...(Object.keys(labels).length > 0 ? { labels } : {}),
});
const updatedResult = await client.fetchAgent(agentId);
const updatedResult = await client.fetchAgent({ agentId });
if (!updatedResult) {
throw new Error(`Agent not found after update: ${agentId}`);
}
+4 -1
View File
@@ -32,6 +32,7 @@ export interface AgentWaitOptions extends CommandOptions {
}
const WAIT_ACTIVITY_PREVIEW_COUNT = 5;
const WAIT_ACTIVITY_PREVIEW_TIMEOUT_MS = 2_000;
function appendRecentActivity(message: string, transcript: string | null): string {
if (!transcript || transcript.trim().length === 0) {
@@ -46,7 +47,9 @@ async function getRecentActivityTranscript(
agentId: string,
): Promise<string | null> {
try {
const timelineItems = await fetchAgentTimelineItems(client, agentId);
const timelineItems = await fetchAgentTimelineItems(client, agentId, {
timeoutMs: WAIT_ACTIVITY_PREVIEW_TIMEOUT_MS,
});
return formatAgentActivityTranscript(timelineItems, WAIT_ACTIVITY_PREVIEW_COUNT);
} catch {
return null;
+13 -3
View File
@@ -26,6 +26,7 @@ export async function connectChatClient(host?: string) {
export async function attachAgentNamesToMessages(
client: Awaited<ReturnType<typeof connectToDaemon>>,
messages: ChatMessageRow[],
options: { timeout?: number; bestEffort?: boolean } = {},
): Promise<ChatMessageRow[]> {
const agentIds = new Set<string>();
for (const message of messages) {
@@ -39,9 +40,18 @@ export async function attachAgentNamesToMessages(
return messages;
}
const payload = await client.fetchAgents({
filter: { includeArchived: true },
});
let payload: Awaited<ReturnType<typeof client.fetchAgents>>;
try {
payload = await client.fetchAgents({
filter: { includeArchived: true },
...(typeof options.timeout === "number" ? { timeout: options.timeout } : {}),
});
} catch (error) {
if (options.bestEffort) {
return messages;
}
throw error;
}
const agentNames = new Map<string, string>();
for (const entry of payload.entries) {
const title = entry.agent.title?.trim();
+19 -1
View File
@@ -13,26 +13,44 @@ export interface ChatWaitOptions extends ChatCommandOptions {
timeout?: string;
}
const CHAT_WAIT_PREFLIGHT_TIMEOUT_MS = 2000;
export async function runWaitCommand(
room: string,
options: ChatWaitOptions,
_command: Command,
): Promise<ListResult<ChatMessageRow>> {
const timeoutMs = parseTimeoutMs(options.timeout);
const { client } = await connectChatClient(options.host);
const deadline = typeof timeoutMs === "number" ? Date.now() + timeoutMs : null;
const hasExplicitTimeout = deadline !== null;
const remainingTimeoutMs = () =>
deadline === null ? undefined : Math.max(1, deadline - Date.now());
try {
const latest = await client.readChatMessages({
room,
limit: 1,
...(hasExplicitTimeout
? {
timeout: Math.min(remainingTimeoutMs() ?? 1, CHAT_WAIT_PREFLIGHT_TIMEOUT_MS),
}
: {}),
});
const afterMessageId = latest.messages[0]?.id;
const payload = await client.waitForChatMessages({
room,
afterMessageId,
timeoutMs: parseTimeoutMs(options.timeout),
timeoutMs: remainingTimeoutMs() ?? timeoutMs,
});
const messages = await attachAgentNamesToMessages(
client,
payload.messages.map(toChatMessageRow),
hasExplicitTimeout
? {
timeout: remainingTimeoutMs(),
bestEffort: true,
}
: {},
);
return {
type: "list",
@@ -667,7 +667,9 @@ async function requestLifecycleShutdown(
};
}
const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) });
const deadline = Date.now() + timeoutMs;
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
const client = await tryConnectToDaemon({ host, timeout: Math.min(remainingTimeoutMs(), 5000) });
if (!client) {
return {
requested: false,
@@ -676,7 +678,7 @@ async function requestLifecycleShutdown(
}
try {
await client.shutdownServer();
await client.shutdownServer({ timeout: Math.min(remainingTimeoutMs(), 5000) });
return { requested: true };
} catch (error) {
return {
@@ -696,8 +698,10 @@ export async function stopLocalDaemon(
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
const state = resolveLocalDaemonState({ home: options.home });
const deadline = Date.now() + timeoutMs;
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs);
const shutdownAttempt = await requestLifecycleShutdown(state, remainingTimeoutMs());
const lifecycleRequested = shutdownAttempt.requested;
if (!state.pidInfo || (!state.running && !lifecycleRequested)) {
@@ -720,7 +724,7 @@ export async function stopLocalDaemon(
const { stopped, forced } = await waitForStopAfterRequest({
state,
pid,
timeoutMs,
timeoutMs: remainingTimeoutMs(),
killTimeoutMs,
force: options.force,
});
+5 -1
View File
@@ -10,6 +10,8 @@ interface PairOptions {
json?: boolean;
}
const PAIRING_DAEMON_RPC_TIMEOUT_MS = 1500;
export function pairCommand(): Command {
return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
@@ -35,7 +37,9 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
if (supportsDaemonStatusRpc) {
try {
const offer = await client.getDaemonPairingOffer();
const offer = await client.getDaemonPairingOffer({
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
await client.close().catch(() => {});
outputPairingResult(
{ relayEnabled: offer.relayEnabled, url: offer.url, qr: offer.qr ?? null },
+9 -2
View File
@@ -6,6 +6,8 @@ import type { CommandOptions, ListResult, OutputSchema } from "../../output/inde
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
import { resolveNodePathFromPid } from "./runtime-toolchain.js";
const DAEMON_STATUS_PROBE_TIMEOUT_MS = 1500;
interface ProviderBinaryStatus {
label: string;
path: string | null;
@@ -276,7 +278,10 @@ async function probeDaemonOverWebsocket(args: {
const supportsDaemonStatusRpc =
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agentsPayload = await client.fetchAgents({
filter: { includeArchived: true },
timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS,
});
const agents = agentsPayload.entries.map((entry) => entry.agent);
const runningAgents = agents.filter((a) => a.status === "running").length;
const idleAgents = agents.filter((a) => a.status === "idle").length;
@@ -284,7 +289,9 @@ async function probeDaemonOverWebsocket(args: {
let daemonProviders: ProviderBinaryStatus[] | undefined;
if (supportsDaemonStatusRpc) {
try {
const statusPayload = await client.getDaemonStatus();
const statusPayload = await client.getDaemonStatus({
timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS,
});
const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label]));
daemonProviders = statusPayload.providers.map((p) => ({
label: labelMap.get(p.provider) ?? p.provider,
+27 -13
View File
@@ -40,6 +40,7 @@ type OnboardPersistedConfig = PersistedConfig & {
};
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000;
const READY_PROBE_TIMEOUT_MS = 1200;
class OnboardCancelledError extends Error {}
@@ -201,15 +202,22 @@ function renderProgressLine(progress: DownloadProgress): string {
type ProbeResult = { kind: "ready"; listen: string; host: string | null } | { kind: "pending" };
async function probeDaemonReady(home: string): Promise<ProbeResult> {
async function probeDaemonReady(home: string, timeoutMs: number): Promise<ProbeResult> {
const state = resolveLocalDaemonState({ home });
const host = resolveTcpHostFromListen(state.listen);
const deadline = Date.now() + timeoutMs;
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
if (state.running && host) {
const client = await tryConnectToDaemon({ host, timeout: 1200 });
const client = await tryConnectToDaemon({
host,
timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS),
});
if (client) {
try {
await client.fetchAgents();
await client.fetchAgents({
timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS),
});
return { kind: "ready", listen: state.listen, host };
} catch {
// Daemon process is alive but not API-ready yet.
@@ -255,23 +263,29 @@ async function waitForDaemonReady(args: {
onStatus?: (message: string) => void;
}): Promise<{ listen: string; host: string | null }> {
const deadline = Date.now() + args.timeoutMs;
const createTimeoutError = () => {
const recentLogs = tailDaemonLog(args.home, 60);
return new Error(
[
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join("\n\n"),
);
};
async function poll(state: ProgressState): Promise<{ listen: string; host: string | null }> {
const probe = await probeDaemonReady(args.home);
if (Date.now() >= deadline) {
throw createTimeoutError();
}
const probe = await probeDaemonReady(args.home, Math.max(1, deadline - Date.now()));
if (probe.kind === "ready") {
return { listen: probe.listen, host: probe.host };
}
const nextState = announceProgress(args.home, state, args.onStatus);
if (Date.now() >= deadline) {
const recentLogs = tailDaemonLog(args.home, 60);
throw new Error(
[
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join("\n\n"),
);
throw createTimeoutError();
}
await sleep(200);
return poll(nextState);
+1 -1
View File
@@ -79,7 +79,7 @@ export async function runAllowCommand(
}
try {
const fetchResult = await client.fetchAgent(agentIdOrPrefix);
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
const error: CommandError = {
+1 -1
View File
@@ -45,7 +45,7 @@ export async function runDenyCommand(
}
try {
const fetchResult = await client.fetchAgent(agentIdOrPrefix);
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
const error: CommandError = {
+4
View File
@@ -1,9 +1,12 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
export const LIVE_HISTORY_FETCH_TIMEOUT_MS = 2_000;
interface FetchProjectedTimelineItemsInput {
client: DaemonClient;
agentId: string;
timeoutMs?: number;
}
export async function fetchProjectedTimelineItems(
@@ -13,6 +16,7 @@ export async function fetchProjectedTimelineItems(
direction: "tail",
limit: 0,
projection: "projected",
timeout: input.timeoutMs,
});
return timeline.entries.map((entry) => entry.item);
}
+632 -16
View File
@@ -522,6 +522,520 @@ test("keeps the transport connected when a session RPC ping times out", async ()
expect(client.getConnectionState().status).toBe("connected");
});
test("defaults session RPC waiters to sixty seconds", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.fetchAgent({
agentId: "agent-1",
requestId: "req-agent-1",
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "fetch_agent_request",
requestId: "req-agent-1",
agentId: "agent-1",
});
await vi.advanceTimersByTimeAsync(59_999);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (60000ms)");
});
test("honors explicit fetchAgent timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.fetchAgent({
agentId: "agent-1",
requestId: "req-agent-1",
timeout: 5_000,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "fetch_agent_request",
requestId: "req-agent-1",
agentId: "agent-1",
});
await vi.advanceTimersByTimeAsync(4_999);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (5000ms)");
});
test("preserves legacy fetchAgent id overload", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.fetchAgent("agent-1", "req-agent-legacy");
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "fetch_agent_request",
requestId: "req-agent-legacy",
agentId: "agent-1",
});
mock.triggerMessage(
wrapSessionMessage({
type: "fetch_agent_response",
payload: {
requestId: "req-agent-legacy",
agent: null,
project: null,
error: "legacy fetch sentinel",
},
}),
);
await expect(responsePromise).rejects.toThrow("legacy fetch sentinel");
});
test("honors explicit fetchAgentTimeline timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.fetchAgentTimeline("agent-1", {
requestId: "req-timeline-1",
direction: "tail",
limit: 0,
projection: "projected",
timeout: 2_000,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "fetch_agent_timeline_request",
requestId: "req-timeline-1",
agentId: "agent-1",
direction: "tail",
limit: 0,
projection: "projected",
});
await vi.advanceTimersByTimeAsync(1_999);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (2000ms)");
});
test("honors explicit fetchAgents timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.fetchAgents({
requestId: "req-agents-1",
scope: "active",
timeout: 1_200,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "fetch_agents_request",
requestId: "req-agents-1",
scope: "active",
});
await vi.advanceTimersByTimeAsync(1_199);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1200ms)");
});
test("honors explicit shutdownServer timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.shutdownServer({
requestId: "req-shutdown-1",
timeout: 1_500,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "shutdown_server_request",
requestId: "req-shutdown-1",
});
await vi.advanceTimersByTimeAsync(1_499);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
});
test("honors explicit readChatMessages timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.readChatMessages({
requestId: "req-chat-read-1",
room: "room-1",
limit: 1,
timeout: 2_500,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "chat/read",
requestId: "req-chat-read-1",
room: "room-1",
limit: 1,
});
await vi.advanceTimersByTimeAsync(2_499);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (2500ms)");
});
test("honors explicit getDaemonStatus timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.getDaemonStatus({
requestId: "req-daemon-status-1",
timeout: 1_500,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "daemon.get_status.request",
requestId: "req-daemon-status-1",
});
await vi.advanceTimersByTimeAsync(1_499);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
});
test("honors explicit getDaemonPairingOffer timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.getDaemonPairingOffer({
requestId: "req-pairing-offer-1",
timeout: 1_500,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "daemon.get_pairing_offer.request",
requestId: "req-pairing-offer-1",
});
await vi.advanceTimersByTimeAsync(1_499);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
});
test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const waitPromise = client.waitForAgentUpsert("agent-1", () => false, 5_000);
let settled = false;
void waitPromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "fetch_agent_request",
requestId: expect.any(String),
agentId: "agent-1",
});
await vi.advanceTimersByTimeAsync(4_999);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(waitPromise).rejects.toThrow("Timed out waiting for agent agent-1");
});
test("keeps default connect timeout shorter than session RPC waiters", async () => {
useHeartbeatClock();
try {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
let settled = false;
const pendingConnect = client.connect().then(
() => {
settled = true;
return { ok: true as const };
},
(error) => {
settled = true;
return { ok: false as const, error };
},
);
await vi.advanceTimersByTimeAsync(14_999);
expect(settled).toBe(false);
expect(client.getConnectionState().status).toBe("connecting");
await vi.advanceTimersByTimeAsync(1);
const result = await pendingConnect;
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(Error);
if (result.error instanceof Error) {
expect(result.error.message).toContain("Connection timed out");
}
}
expect(client.getConnectionState().status).toBe("disconnected");
} finally {
vi.useRealTimers();
}
});
test("stays online through ten minutes of pongs that arrive five seconds late", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
@@ -1754,16 +2268,8 @@ test("sends explicit shutdown_server_request via shutdownServer", async () => {
mock.triggerOpen();
await connectPromise;
const lifecycleClient = client as unknown as {
shutdownServer: (requestId?: string) => Promise<{
status: "shutdown_requested";
clientId: string;
requestId: string;
}>;
};
expect(typeof lifecycleClient.shutdownServer).toBe("function");
const promise = lifecycleClient.shutdownServer("req-shutdown-1");
expect(typeof client.shutdownServer).toBe("function");
const promise = client.shutdownServer({ requestId: "req-shutdown-1" });
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
@@ -3347,7 +3853,8 @@ test("lists commands with draft config via RPC", async () => {
mock.triggerOpen();
await connectPromise;
const promise = client.listCommands("__new_agent__", {
const promise = client.listCommands({
agentId: "__new_agent__",
draftConfig: {
provider: "codex",
cwd: "/tmp/project",
@@ -3392,7 +3899,7 @@ test("lists commands with draft config via RPC", async () => {
});
});
test("lists commands with legacy requestId signature via RPC", async () => {
test("lists commands with explicit requestId via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -3409,13 +3916,16 @@ test("lists commands with legacy requestId signature via RPC", async () => {
mock.triggerOpen();
await connectPromise;
const promise = client.listCommands("agent-1", "req-legacy");
const promise = client.listCommands({
agentId: "agent-1",
requestId: "req-commands",
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("list_commands_request");
expect(request.agentId).toBe("agent-1");
expect(request.requestId).toBe("req-legacy");
expect(request.requestId).toBe("req-commands");
expect(request.draftConfig).toBeUndefined();
mock.triggerMessage(
@@ -3427,7 +3937,7 @@ test("lists commands with legacy requestId signature via RPC", async () => {
agentId: "agent-1",
commands: [],
error: null,
requestId: "req-legacy",
requestId: "req-commands",
},
},
}),
@@ -3437,7 +3947,113 @@ test("lists commands with legacy requestId signature via RPC", async () => {
agentId: "agent-1",
commands: [],
error: null,
requestId: "req-legacy",
requestId: "req-commands",
});
});
test("preserves legacy listCommands id overload", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.listCommands("agent-1", "req-commands-legacy");
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "list_commands_request",
requestId: "req-commands-legacy",
agentId: "agent-1",
});
mock.triggerMessage(
wrapSessionMessage({
type: "list_commands_response",
payload: {
agentId: "agent-1",
commands: [],
error: null,
requestId: "req-commands-legacy",
},
}),
);
await expect(promise).resolves.toEqual({
agentId: "agent-1",
commands: [],
error: null,
requestId: "req-commands-legacy",
});
});
test("preserves legacy listCommands options overload", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.listCommands("__new_agent__", {
requestId: "req-commands-draft-legacy",
draftConfig: {
provider: "codex",
cwd: "/tmp/project",
modeId: "bypassPermissions",
model: "gpt-5",
thinkingOptionId: "off",
},
});
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "list_commands_request",
requestId: "req-commands-draft-legacy",
agentId: "__new_agent__",
draftConfig: {
provider: "codex",
cwd: "/tmp/project",
modeId: "bypassPermissions",
model: "gpt-5",
thinkingOptionId: "off",
},
});
mock.triggerMessage(
wrapSessionMessage({
type: "list_commands_response",
payload: {
agentId: "__new_agent__",
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
error: null,
requestId: "req-commands-draft-legacy",
},
}),
);
await expect(promise).resolves.toEqual({
agentId: "__new_agent__",
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
error: null,
requestId: "req-commands-draft-legacy",
});
});
+111 -136
View File
@@ -374,9 +374,11 @@ export interface WriteProjectConfigInput {
requestId?: string;
}
interface ListCommandsOptions {
agentId: string;
requestId?: string;
draftConfig?: ListCommandsDraftConfig;
}
type LegacyListCommandsOptions = Omit<ListCommandsOptions, "agentId">;
type SetVoiceModePayload = Extract<
SessionOutboundMessage,
{ type: "set_voice_mode_response" }
@@ -458,17 +460,64 @@ export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["paylo
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"];
export type FetchAgentTimelineCursor = NonNullable<FetchAgentTimelinePayload["startCursor"]>;
export interface FetchAgentOptions {
agentId: string;
requestId?: string;
timeout?: number;
}
type LegacyFetchAgentOptions = Omit<FetchAgentOptions, "agentId">;
export interface FetchAgentTimelineOptions {
direction?: FetchAgentTimelineDirection;
cursor?: FetchAgentTimelineCursor;
limit?: number;
projection?: FetchAgentTimelineProjection;
requestId?: string;
timeout?: number;
}
// COMPAT(daemon-client-object-options): added in v0.1.102; remove after
// 2026-12-29 once SDK callers have migrated to object parameters.
function normalizeFetchAgentOptions(
input: FetchAgentOptions | string,
legacyOptions?: LegacyFetchAgentOptions | string,
): FetchAgentOptions {
if (typeof input !== "string") {
return input;
}
if (typeof legacyOptions === "string") {
return { agentId: input, requestId: legacyOptions };
}
return { agentId: input, ...legacyOptions };
}
function normalizeListCommandsOptions(
input: ListCommandsOptions | string,
legacyOptions?: LegacyListCommandsOptions | string,
): ListCommandsOptions {
if (typeof input !== "string") {
return input;
}
if (typeof legacyOptions === "string") {
return { agentId: input, requestId: legacyOptions };
}
return { agentId: input, ...legacyOptions };
}
type AgentRefreshedStatusPayload = z.infer<typeof AgentRefreshedStatusPayloadSchema>;
type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema>;
type ShutdownRequestedStatusPayload = z.infer<typeof ShutdownRequestedStatusPayloadSchema>;
export interface ShutdownServerOptions {
requestId?: string;
timeout?: number;
}
export interface DaemonStatusOptions {
requestId?: string;
timeout?: number;
}
export interface DaemonPairingOfferOptions {
requestId?: string;
timeout?: number;
}
type DaemonUpdateResponse = z.infer<typeof DaemonUpdateResponseSchema>;
type FetchAgentsPayload = Extract<
SessionOutboundMessage,
@@ -477,6 +526,7 @@ type FetchAgentsPayload = Extract<
type FetchAgentsRequest = Extract<SessionInboundMessage, { type: "fetch_agents_request" }>;
export type FetchAgentsOptions = Omit<FetchAgentsRequest, "type" | "requestId"> & {
requestId?: string;
timeout?: number;
};
export type FetchAgentsEntry = FetchAgentsPayload["entries"][number];
export type FetchAgentsPageInfo = FetchAgentsPayload["pageInfo"];
@@ -544,6 +594,7 @@ export interface ReadChatMessagesOptions {
since?: string;
authorAgentId?: string;
requestId?: string;
timeout?: number;
}
export interface WaitForChatMessagesOptions {
room: string;
@@ -762,15 +813,16 @@ function toTimeoutError(error: unknown, label: string, timeoutMs: number): Error
const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500;
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
const DEFAULT_SESSION_RPC_TIMEOUT_MS = 60_000;
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
const DEFAULT_LIVENESS_TIMEOUT_MS = 5000;
const LIVENESS_HEARTBEAT_INTERVAL_MS = 10_000;
const LIVENESS_HEARTBEAT_TIMEOUT_MS = 15_000;
const LIVENESS_FAILURE_RECONNECT_THRESHOLD = 2;
/** Default timeout for waiting for connection before sending queued messages */
const DEFAULT_SEND_QUEUE_TIMEOUT_MS = 10000;
const DEFAULT_DICTATION_FINISH_ACCEPT_TIMEOUT_MS = 15000;
const DEFAULT_SEND_QUEUE_TIMEOUT_MS = DEFAULT_SESSION_RPC_TIMEOUT_MS;
const DEFAULT_DICTATION_FINISH_ACCEPT_TIMEOUT_MS = DEFAULT_SESSION_RPC_TIMEOUT_MS;
const DEFAULT_DICTATION_FINISH_FALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
const DEFAULT_DICTATION_FINISH_TIMEOUT_GRACE_MS = 5000;
@@ -1420,10 +1472,11 @@ export class DaemonClient {
private async sendRequest<T>(params: {
requestId: string;
message: SessionInboundMessage;
timeout: number;
timeout?: number;
select: (msg: SessionOutboundMessage) => T | null;
options?: { skipQueue?: boolean };
}): Promise<T> {
const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS;
const { promise, cancel } = this.waitForWithCancel<RpcWaitResult<T>>(
(msg) => {
if (msg.type === "rpc_error" && msg.payload.requestId === params.requestId) {
@@ -1443,7 +1496,7 @@ export class DaemonClient {
}
return { kind: "ok", value };
},
params.timeout,
timeout,
params.options,
);
@@ -1469,7 +1522,7 @@ export class DaemonClient {
>(params: {
requestId: string;
message: SessionInboundMessage;
timeout: number;
timeout?: number;
responseType: TResponseType;
options?: { skipQueue?: boolean };
selectPayload?: (payload: CorrelatedResponsePayload<TResponseType>) => TResult | null;
@@ -1503,7 +1556,7 @@ export class DaemonClient {
requestId?: string;
message: { type: SessionInboundMessage["type"] } & Record<string, unknown>;
responseType: TResponseType;
timeout: number;
timeout?: number;
selectPayload?: (payload: CorrelatedResponsePayload<TResponseType>) => TResult | null;
}): Promise<TResult> {
const resolvedRequestId = this.createRequestId(params.requestId);
@@ -1530,7 +1583,7 @@ export class DaemonClient {
string,
unknown
>;
timeout: number;
timeout?: number;
selectPayload?: (payload: CorrelatedResponsePayload<TResponseType>) => TResult | null;
}): Promise<TResult> {
const responseType = params.message.type.replace(/\.request$/, ".response") as TResponseType;
@@ -1562,7 +1615,6 @@ export class DaemonClient {
await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "clear_agent_attention_response") {
@@ -1586,7 +1638,6 @@ export class DaemonClient {
const response = await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "workspace.clear_attention.response") {
@@ -1782,7 +1833,7 @@ export class DaemonClient {
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
timeout: options?.timeout,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "fetch_agents_response") {
@@ -1808,7 +1859,6 @@ export class DaemonClient {
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "fetch_agent_history_response") {
@@ -1837,7 +1887,6 @@ export class DaemonClient {
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "fetch_recent_provider_sessions_response") {
@@ -1864,7 +1913,6 @@ export class DaemonClient {
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "fetch_workspaces_response") {
@@ -1886,9 +1934,6 @@ export class DaemonClient {
cwd,
},
responseType: "open_project_response",
// Large local repos (e.g. a big monorepo/brain checkout) need >10s for the
// daemon to resolve the path, detect git, and materialize the workspace.
timeout: 60000,
});
}
@@ -1900,7 +1945,6 @@ export class DaemonClient {
cwd,
},
responseType: "project.add.response",
timeout: 10000,
});
}
@@ -1919,7 +1963,6 @@ export class DaemonClient {
scriptName,
},
responseType: "start_workspace_script_response",
timeout: 10000,
});
}
@@ -1934,7 +1977,6 @@ export class DaemonClient {
workspaceId,
},
responseType: "archive_workspace_response",
timeout: 10000,
});
}
@@ -1949,21 +1991,30 @@ export class DaemonClient {
workspaceId,
},
responseType: "workspace_setup_status_response",
timeout: 10000,
});
}
async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null> {
const resolvedRequestId = this.createRequestId(requestId);
async fetchAgent(options: FetchAgentOptions): Promise<FetchAgentResult | null>;
async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null>;
async fetchAgent(
agentId: string,
options?: LegacyFetchAgentOptions,
): Promise<FetchAgentResult | null>;
async fetchAgent(
input: FetchAgentOptions | string,
legacyOptions?: LegacyFetchAgentOptions | string,
): Promise<FetchAgentResult | null> {
const options = normalizeFetchAgentOptions(input, legacyOptions);
const resolvedRequestId = this.createRequestId(options.requestId);
const message = SessionInboundMessageSchema.parse({
type: "fetch_agent_request",
requestId: resolvedRequestId,
agentId,
agentId: options.agentId,
});
const payload = await this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
timeout: options.timeout,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "fetch_agent_response") {
@@ -2048,7 +2099,6 @@ export class DaemonClient {
const status = await this.sendRequest({
requestId,
message,
timeout: 60000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "status") {
@@ -2082,7 +2132,6 @@ export class DaemonClient {
await this.sendRequest({
requestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "agent_deleted") {
@@ -2106,7 +2155,6 @@ export class DaemonClient {
const result = await this.sendRequest({
requestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "agent_archived") {
@@ -2127,7 +2175,6 @@ export class DaemonClient {
type: "agent.detach.request",
agentId,
},
timeout: 10000,
});
if (!payload.accepted) {
throw new Error(payload.error ?? "detachAgent rejected");
@@ -2151,7 +2198,6 @@ export class DaemonClient {
const payload = await this.sendRequest({
requestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "update_agent_response") {
@@ -2181,7 +2227,6 @@ export class DaemonClient {
customName,
},
responseType: "project.rename.response",
timeout: 10000,
});
if (!payload.accepted) {
throw new Error(payload.error ?? "renameProject rejected");
@@ -2199,7 +2244,6 @@ export class DaemonClient {
type: "project.remove.request",
projectId,
},
timeout: 10000,
});
if (!payload.accepted) {
throw new Error(payload.error ?? "removeProject rejected");
@@ -2220,7 +2264,6 @@ export class DaemonClient {
title,
},
responseType: "workspace.title.set.response",
timeout: 10000,
});
if (!payload.accepted) {
throw new Error(payload.error ?? "setWorkspaceTitle rejected");
@@ -2243,7 +2286,6 @@ export class DaemonClient {
const status = await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "status") {
@@ -2275,7 +2317,6 @@ export class DaemonClient {
const status = await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "status") {
@@ -2312,7 +2353,6 @@ export class DaemonClient {
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "status") {
@@ -2345,7 +2385,7 @@ export class DaemonClient {
const payload = await this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 15000,
timeout: options.timeout,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "fetch_agent_timeline_response") {
@@ -2388,7 +2428,6 @@ export class DaemonClient {
const payload = await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "send_agent_message_response") {
@@ -2425,7 +2464,6 @@ export class DaemonClient {
const payload = await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "agent.rewind.response") {
@@ -2453,7 +2491,6 @@ export class DaemonClient {
await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "cancel_agent_response") {
@@ -2478,7 +2515,6 @@ export class DaemonClient {
const payload = await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "set_agent_mode_response") {
@@ -2507,7 +2543,6 @@ export class DaemonClient {
const payload = await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "set_agent_model_response") {
@@ -2536,7 +2571,6 @@ export class DaemonClient {
const payload = await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "set_agent_feature_response") {
@@ -2567,7 +2601,6 @@ export class DaemonClient {
const payload = await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "set_agent_thinking_response") {
@@ -2595,7 +2628,6 @@ export class DaemonClient {
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "status") {
@@ -2613,8 +2645,8 @@ export class DaemonClient {
});
}
async shutdownServer(requestId?: string): Promise<ShutdownRequestedStatusPayload> {
const resolvedRequestId = this.createRequestId(requestId);
async shutdownServer(options?: ShutdownServerOptions): Promise<ShutdownRequestedStatusPayload> {
const resolvedRequestId = this.createRequestId(options?.requestId);
const message = SessionInboundMessageSchema.parse({
type: "shutdown_server_request",
requestId: resolvedRequestId,
@@ -2622,7 +2654,7 @@ export class DaemonClient {
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
timeout: options?.timeout,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "status") {
@@ -2679,7 +2711,6 @@ export class DaemonClient {
const response = await this.sendRequest({
requestId,
message,
timeout: 10000,
select: (msg) => {
if (msg.type !== "set_voice_mode_response") {
return null;
@@ -2948,7 +2979,6 @@ export class DaemonClient {
const responsePromise = this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 60000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "checkout_status_response") {
@@ -3049,7 +3079,6 @@ export class DaemonClient {
requestId: resolvedRequestId,
message,
responseType: "subscribe_checkout_diff_response",
timeout: 60000,
options: { skipQueue: true },
selectPayload: (payload) => {
if (payload.subscriptionId !== subscriptionId) {
@@ -3090,7 +3119,6 @@ export class DaemonClient {
addAll: input.addAll,
},
responseType: "checkout_commit_response",
timeout: 60000,
});
}
@@ -3109,7 +3137,6 @@ export class DaemonClient {
requireCleanTarget: input.requireCleanTarget,
},
responseType: "checkout_merge_response",
timeout: 60000,
});
}
@@ -3127,7 +3154,6 @@ export class DaemonClient {
requireCleanTarget: input.requireCleanTarget,
},
responseType: "checkout_merge_from_base_response",
timeout: 60000,
});
}
@@ -3139,7 +3165,6 @@ export class DaemonClient {
cwd,
},
responseType: "checkout_pull_response",
timeout: 60000,
});
}
@@ -3151,7 +3176,6 @@ export class DaemonClient {
cwd,
},
responseType: "checkout_push_response",
timeout: 60000,
});
}
@@ -3163,7 +3187,6 @@ export class DaemonClient {
cwd,
},
responseType: "checkout.refresh.response",
timeout: 60000,
});
}
@@ -3182,7 +3205,6 @@ export class DaemonClient {
baseRef: input.baseRef,
},
responseType: "checkout_pr_create_response",
timeout: 60000,
});
}
@@ -3199,7 +3221,6 @@ export class DaemonClient {
mergeMethod: input.method,
},
responseType: "checkout_pr_merge_response",
timeout: 60000,
});
}
@@ -3216,7 +3237,6 @@ export class DaemonClient {
enabled: input.enabled,
...(input.enabled ? { mergeMethod: input.method } : {}),
},
timeout: 60000,
});
}
@@ -3241,7 +3261,6 @@ export class DaemonClient {
checkRunId: input.checkRunId,
workflowRunId: input.workflowRunId,
},
timeout: 60000,
},
);
}
@@ -3254,7 +3273,6 @@ export class DaemonClient {
cwd,
},
responseType: "checkout_pr_status_response",
timeout: 60000,
});
}
@@ -3272,7 +3290,6 @@ export class DaemonClient {
repoName: input.repoName,
},
responseType: "pull_request_timeline_response",
timeout: 60000,
});
}
@@ -3289,7 +3306,6 @@ export class DaemonClient {
branch,
},
responseType: "checkout_switch_branch_response",
timeout: 30000,
});
}
@@ -3302,7 +3318,6 @@ export class DaemonClient {
branch: input.branch,
},
responseType: "checkout.rename_branch.response",
timeout: 30000,
});
}
@@ -3319,7 +3334,6 @@ export class DaemonClient {
branch: options?.branch,
},
responseType: "stash_save_response",
timeout: 30000,
});
}
@@ -3332,7 +3346,6 @@ export class DaemonClient {
stashIndex,
},
responseType: "stash_pop_response",
timeout: 30000,
});
}
@@ -3349,7 +3362,6 @@ export class DaemonClient {
paseoOnly: options?.paseoOnly,
},
responseType: "stash_list_response",
timeout: 10000,
});
}
@@ -3365,7 +3377,6 @@ export class DaemonClient {
repoRoot: input.repoRoot,
},
responseType: "paseo_worktree_list_response",
timeout: 60000,
});
}
@@ -3390,7 +3401,6 @@ export class DaemonClient {
...(input.scope !== undefined ? { scope: input.scope } : {}),
},
responseType: "paseo_worktree_archive_response",
timeout: 60000,
});
}
@@ -3413,7 +3423,6 @@ export class DaemonClient {
...(input.githubPrNumber !== undefined ? { githubPrNumber: input.githubPrNumber } : {}),
},
responseType: "create_paseo_worktree_response",
timeout: 60000,
});
}
@@ -3436,7 +3445,6 @@ export class DaemonClient {
: {}),
},
responseType: "workspace.create.response",
timeout: 60000,
});
}
@@ -3452,7 +3460,6 @@ export class DaemonClient {
branchName: options.branchName,
},
responseType: "validate_branch_response",
timeout: 10000,
});
}
@@ -3469,7 +3476,6 @@ export class DaemonClient {
limit: options.limit,
},
responseType: "branch_suggestions_response",
timeout: 10000,
});
}
@@ -3487,7 +3493,6 @@ export class DaemonClient {
kinds: options.kinds,
},
responseType: "github_search_response",
timeout: 15000,
});
}
@@ -3516,7 +3521,6 @@ export class DaemonClient {
responseType: "directory_suggestions_response",
// Home-tree scans on large home dirs can take several seconds; don't cut
// the suggestion request off early (it would surface as an empty list).
timeout: 30000,
});
}
@@ -3541,7 +3545,6 @@ export class DaemonClient {
...(acceptBinary ? { acceptBinary: true } : {}),
},
responseType: "file_explorer_response",
timeout: 10000,
});
}
@@ -3601,7 +3604,6 @@ export class DaemonClient {
requestId: resolvedRequestId,
},
responseType: "file.upload.response",
timeout: 60000,
options: { skipQueue: true },
});
@@ -3653,7 +3655,6 @@ export class DaemonClient {
path,
},
responseType: "file_download_token_response",
timeout: 10000,
});
}
@@ -3668,7 +3669,6 @@ export class DaemonClient {
cwd,
},
responseType: "project_icon_response",
timeout: 10000,
});
}
@@ -3733,7 +3733,6 @@ export class DaemonClient {
type: "list_available_providers_request",
},
responseType: "list_available_providers_response",
timeout: 60000,
});
}
@@ -3748,7 +3747,6 @@ export class DaemonClient {
cwd: options?.cwd,
},
responseType: "get_providers_snapshot_response",
timeout: 10000,
});
}
@@ -3761,29 +3759,30 @@ export class DaemonClient {
type: "get_daemon_config_request",
},
responseType: "get_daemon_config_response",
timeout: 10000,
});
}
async getDaemonStatus(requestId?: string): Promise<DaemonStatusPayload> {
async getDaemonStatus(options?: DaemonStatusOptions): Promise<DaemonStatusPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
requestId: options?.requestId,
message: {
type: "daemon.get_status.request",
},
responseType: "daemon.get_status.response",
timeout: 10000,
timeout: options?.timeout,
});
}
async getDaemonPairingOffer(requestId?: string): Promise<DaemonPairingOfferPayload> {
async getDaemonPairingOffer(
options?: DaemonPairingOfferOptions,
): Promise<DaemonPairingOfferPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
requestId: options?.requestId,
message: {
type: "daemon.get_pairing_offer.request",
},
responseType: "daemon.get_pairing_offer.response",
timeout: 10000,
timeout: options?.timeout,
});
}
@@ -3793,7 +3792,6 @@ export class DaemonClient {
message: {
type: "diagnostics.request",
},
timeout: 30000,
});
}
@@ -3808,7 +3806,6 @@ export class DaemonClient {
config,
},
responseType: "set_daemon_config_response",
timeout: 10000,
});
}
@@ -3820,7 +3817,6 @@ export class DaemonClient {
repoRoot,
},
responseType: "read_project_config_response",
timeout: 10000,
});
}
@@ -3834,7 +3830,6 @@ export class DaemonClient {
expectedRevision: input.expectedRevision,
},
responseType: "write_project_config_response",
timeout: 10000,
});
}
@@ -3876,30 +3871,28 @@ export class DaemonClient {
message: {
type: "provider.usage.list.request",
},
timeout: 30000,
});
}
async listCommands(options: ListCommandsOptions): Promise<ListCommandsPayload>;
async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>;
async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>;
async listCommands(
agentId: string,
requestIdOrOptions?: string | ListCommandsOptions,
options?: LegacyListCommandsOptions,
): Promise<ListCommandsPayload>;
async listCommands(
input: ListCommandsOptions | string,
legacyOptions?: LegacyListCommandsOptions | string,
): Promise<ListCommandsPayload> {
const requestId =
typeof requestIdOrOptions === "string" ? requestIdOrOptions : requestIdOrOptions?.requestId;
const draftConfig =
typeof requestIdOrOptions === "string" ? undefined : requestIdOrOptions?.draftConfig;
const options = normalizeListCommandsOptions(input, legacyOptions);
return this.sendCorrelatedSessionRequest({
requestId,
requestId: options.requestId,
message: {
type: "list_commands_request",
agentId,
...(draftConfig ? { draftConfig } : {}),
agentId: options.agentId,
...(options.draftConfig ? { draftConfig: options.draftConfig } : {}),
},
responseType: "list_commands_response",
timeout: 30000,
});
}
@@ -3961,12 +3954,20 @@ export class DaemonClient {
predicate: (snapshot: AgentSnapshotPayload) => boolean,
timeout = 60000,
): Promise<AgentSnapshotPayload> {
const initialResult = await this.fetchAgent(agentId).catch(() => null);
const deadline = Date.now() + timeout;
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
const timeoutError = () => new Error(`Timed out waiting for agent ${agentId}`);
const fetchAgentWithinDeadline = () =>
this.fetchAgent({ agentId, timeout: remainingTimeoutMs() }).catch(() => null);
const initialResult = await fetchAgentWithinDeadline();
if (initialResult && predicate(initialResult.agent)) {
return initialResult.agent;
}
if (Date.now() >= deadline) {
throw timeoutError();
}
const deadline = Date.now() + timeout;
return await new Promise<AgentSnapshotPayload>((resolve, reject) => {
let settled = false;
let pollInFlight = false;
@@ -4017,7 +4018,7 @@ export class DaemonClient {
}
pollInFlight = true;
try {
const result = await this.fetchAgent(agentId).catch(() => null);
const result = await fetchAgentWithinDeadline();
maybeResolve(result?.agent ?? null);
} finally {
pollInFlight = false;
@@ -4042,7 +4043,7 @@ export class DaemonClient {
timeoutTimer = setTimeout(() => {
finish({
kind: "error",
error: new Error(`Timed out waiting for agent ${agentId}`),
error: timeoutError(),
});
}, remaining);
@@ -4126,7 +4127,6 @@ export class DaemonClient {
requestId: resolvedRequestId,
message,
responseType: "list_terminals_response",
timeout: 10000,
options: { skipQueue: true },
});
}
@@ -4152,7 +4152,6 @@ export class DaemonClient {
requestId: resolvedRequestId,
message,
responseType: "create_terminal_response",
timeout: 10000,
options: { skipQueue: true },
});
}
@@ -4166,7 +4165,6 @@ export class DaemonClient {
title: input.title,
},
responseType: "terminal.rename.response",
timeout: 10000,
});
}
@@ -4190,7 +4188,6 @@ export class DaemonClient {
requestId: resolvedRequestId,
message,
responseType: "subscribe_terminal_response",
timeout: 10000,
options: { skipQueue: true },
});
if (payload.error === null) {
@@ -4231,7 +4228,6 @@ export class DaemonClient {
requestId: resolvedRequestId,
message,
responseType: "kill_terminal_response",
timeout: 10000,
options: { skipQueue: true },
});
}
@@ -4251,7 +4247,6 @@ export class DaemonClient {
requestId: resolvedRequestId,
message,
responseType: "close_items_response",
timeout: 10000,
options: { skipQueue: true },
});
}
@@ -4274,7 +4269,6 @@ export class DaemonClient {
requestId: resolvedRequestId,
message,
responseType: "capture_terminal_response",
timeout: 10000,
options: { skipQueue: true },
});
}
@@ -4288,7 +4282,6 @@ export class DaemonClient {
...(options.purpose ? { purpose: options.purpose } : {}),
},
responseType: "chat/create/response",
timeout: 10000,
});
}
@@ -4299,7 +4292,6 @@ export class DaemonClient {
type: "chat/list",
},
responseType: "chat/list/response",
timeout: 10000,
});
}
@@ -4311,7 +4303,6 @@ export class DaemonClient {
room: options.room,
},
responseType: "chat/inspect/response",
timeout: 10000,
});
}
@@ -4323,7 +4314,6 @@ export class DaemonClient {
room: options.room,
},
responseType: "chat/delete/response",
timeout: 10000,
});
}
@@ -4338,7 +4328,6 @@ export class DaemonClient {
...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}),
},
responseType: "chat/post/response",
timeout: 10000,
});
}
@@ -4353,7 +4342,7 @@ export class DaemonClient {
...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}),
},
responseType: "chat/read/response",
timeout: 10000,
timeout: options.timeout,
});
}
@@ -4385,7 +4374,6 @@ export class DaemonClient {
...(typeof options.runOnCreate === "boolean" ? { runOnCreate: options.runOnCreate } : {}),
},
responseType: "schedule/create/response",
timeout: 10000,
});
}
@@ -4396,7 +4384,6 @@ export class DaemonClient {
type: "schedule/list",
},
responseType: "schedule/list/response",
timeout: 10000,
});
}
@@ -4408,7 +4395,6 @@ export class DaemonClient {
scheduleId: options.id,
},
responseType: "schedule/inspect/response",
timeout: 10000,
});
}
@@ -4420,7 +4406,6 @@ export class DaemonClient {
scheduleId: options.id,
},
responseType: "schedule/logs/response",
timeout: 10000,
});
}
@@ -4432,7 +4417,6 @@ export class DaemonClient {
scheduleId: options.id,
},
responseType: "schedule/pause/response",
timeout: 10000,
});
}
@@ -4444,7 +4428,6 @@ export class DaemonClient {
scheduleId: options.id,
},
responseType: "schedule/resume/response",
timeout: 10000,
});
}
@@ -4456,7 +4439,6 @@ export class DaemonClient {
scheduleId: options.id,
},
responseType: "schedule/delete/response",
timeout: 10000,
});
}
@@ -4468,7 +4450,6 @@ export class DaemonClient {
scheduleId: options.id,
},
responseType: "schedule/run-once/response",
timeout: 10000,
});
}
@@ -4486,7 +4467,6 @@ export class DaemonClient {
...(options.expiresAt !== undefined ? { expiresAt: options.expiresAt } : {}),
},
responseType: "schedule/update/response",
timeout: 10000,
});
}
@@ -4515,7 +4495,6 @@ export class DaemonClient {
...(typeof options.maxTimeMs === "number" ? { maxTimeMs: options.maxTimeMs } : {}),
},
responseType: "loop/run/response",
timeout: 15000,
});
}
@@ -4526,7 +4505,6 @@ export class DaemonClient {
type: "loop/list",
},
responseType: "loop/list/response",
timeout: 10000,
});
}
@@ -4539,7 +4517,6 @@ export class DaemonClient {
id: normalized.id,
},
responseType: "loop/inspect/response",
timeout: 10000,
});
}
@@ -4553,7 +4530,6 @@ export class DaemonClient {
...(typeof normalized.afterSeq === "number" ? { afterSeq: normalized.afterSeq } : {}),
},
responseType: "loop/logs/response",
timeout: 10000,
});
}
@@ -4566,7 +4542,6 @@ export class DaemonClient {
id: normalized.id,
},
responseType: "loop/stop/response",
timeout: 10000,
});
}
+1 -1
View File
@@ -472,7 +472,7 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
},
latest: () => latest,
refetch: async (requestId) => {
const result = await daemonClient.fetchAgent(id, requestId);
const result = await daemonClient.fetchAgent({ agentId: id, requestId });
latest = result?.agent ?? null;
return result;
},
@@ -28,7 +28,7 @@ describe("claude agent commands E2E", () => {
expect(agent.status).toBe("idle");
// List commands
const result = await ctx.client.listCommands(agent.id);
const result = await ctx.client.listCommands({ agentId: agent.id });
// Should have no error
expect(result.error).toBeNull();
@@ -52,7 +52,7 @@ describe("claude agent commands E2E", () => {
}, 60000);
test("returns error for non-existent agent", async () => {
const result = await ctx.client.listCommands("non-existent-agent-id");
const result = await ctx.client.listCommands({ agentId: "non-existent-agent-id" });
expect(result.error).toBeTruthy();
expect(result.error).toContain("Agent not found");
@@ -67,7 +67,7 @@ describe("codex agent commands E2E", () => {
expect(agent.provider).toBe("codex");
expect(agent.status).toBe("idle");
const result = await ctx.client.listCommands(agent.id);
const result = await ctx.client.listCommands({ agentId: agent.id });
expect(result.error).toBeNull();
expect(result.commands.length).toBeGreaterThan(0);
@@ -177,7 +177,7 @@ describe("codex agent commands E2E", () => {
}, 30_000);
test("returns error for non-existent agent", async () => {
const result = await ctx.client.listCommands("non-existent-agent-id");
const result = await ctx.client.listCommands({ agentId: "non-existent-agent-id" });
expect(result.error).toBeTruthy();
expect(result.error).toContain("Agent not found");
@@ -24,7 +24,7 @@ describe("opencode agent commands E2E", () => {
expect(agent.provider).toBe("opencode");
expect(agent.status).toBe("idle");
const result = await ctx.client.listCommands(agent.id);
const result = await ctx.client.listCommands({ agentId: agent.id });
expect(result.error).toBeNull();
expect(result.commands.length).toBeGreaterThan(0);
@@ -87,7 +87,7 @@ describe("opencode agent commands E2E", () => {
}, 30_000);
test("returns error for non-existent agent", async () => {
const result = await ctx.client.listCommands("non-existent-agent-id");
const result = await ctx.client.listCommands({ agentId: "non-existent-agent-id" });
expect(result.error).toBeTruthy();
expect(result.error).toContain("Agent not found");
@@ -77,7 +77,7 @@ describe("opencode custom command Big Pickle E2E (real)", () => {
expect(agent.model).toBe(OPENCODE_REAL_TEST_MODEL);
expect(agent.status).toBe("idle");
const commands = await ctx.client.listCommands(agent.id);
const commands = await ctx.client.listCommands({ agentId: agent.id });
expect(commands.error).toBeNull();
expect(commands.commands).toEqual(
expect.arrayContaining([
@@ -53,7 +53,7 @@ test("daemon mints a distinct local workspace per run and stamps agents by id",
expect(firstAgent.workspaceId).toBe(firstWorkspaceId);
expect(await workspaceIds(client)).toContain(firstWorkspaceId);
const fetchedFirst = await client.fetchAgent(firstAgent.id);
const fetchedFirst = await client.fetchAgent({ agentId: firstAgent.id });
expect(fetchedFirst?.agent.workspaceId).toBe(firstWorkspaceId);
// A second bare run in the SAME cwd mints a DISTINCT workspace; each run
@@ -197,12 +197,12 @@ test("createAgent with background initialPrompt returns a running snapshot befor
expect(agent.status).toBe("running");
const fetchedWhileRunning = await client.fetchAgent(agent.id);
const fetchedWhileRunning = await client.fetchAgent({ agentId: agent.id });
expect(fetchedWhileRunning?.agent.status).toBe("running");
await new Promise((resolve) => setTimeout(resolve, 350));
const fetchedAfterCompletion = await client.fetchAgent(agent.id);
const fetchedAfterCompletion = await client.fetchAgent({ agentId: agent.id });
expect(fetchedAfterCompletion?.agent.status).toBe("idle");
} finally {
await client.close();
@@ -638,7 +638,7 @@ test("interrupts a running agent before archiving", async () => {
const result = await ctx.client.archiveAgent(created.id);
expect(result.archivedAt).toBeTruthy();
const archivedResult = await ctx.client.fetchAgent(created.id);
const archivedResult = await ctx.client.fetchAgent({ agentId: created.id });
expect(archivedResult).not.toBeNull();
expect(archivedResult?.agent.archivedAt).toBeTruthy();
expect(archivedResult?.agent.status).not.toBe("running");
@@ -671,7 +671,7 @@ test("send_agent_message auto-unarchives archived agents", async () => {
const finalState = await ctx.client.waitForFinish(created.id, 120000);
expect(finalState.status).toBe("idle");
const refreshed = await ctx.client.fetchAgent(created.id);
const refreshed = await ctx.client.fetchAgent({ agentId: created.id });
expect(refreshed).not.toBeNull();
expect(refreshed?.agent.archivedAt).toBeNull();
} finally {
@@ -691,7 +691,7 @@ test("refresh_agent auto-unarchives archived agents", async () => {
await ctx.client.archiveAgent(created.id);
await ctx.client.refreshAgent(created.id);
const refreshed = await ctx.client.fetchAgent(created.id);
const refreshed = await ctx.client.fetchAgent({ agentId: created.id });
expect(refreshed).not.toBeNull();
expect(refreshed?.agent.archivedAt).toBeNull();
} finally {
@@ -770,7 +770,7 @@ test("resume_agent auto-unarchives archived agents", async () => {
cwd,
},
});
const agentBeforeArchive = await ctx.client.fetchAgent(created.id);
const agentBeforeArchive = await ctx.client.fetchAgent({ agentId: created.id });
expect(agentBeforeArchive?.agent.persistence).toBeTruthy();
await ctx.client.archiveAgent(created.id);
@@ -779,7 +779,7 @@ test("resume_agent auto-unarchives archived agents", async () => {
throw new Error("Expected persistence handle for resume test");
}
const resumed = await ctx.client.resumeAgent(handle);
const resumedDetails = await ctx.client.fetchAgent(resumed.id);
const resumedDetails = await ctx.client.fetchAgent({ agentId: resumed.id });
expect(resumedDetails).not.toBeNull();
expect(resumedDetails?.agent.archivedAt).toBeNull();
@@ -807,7 +807,7 @@ test("update_agent persists unloaded title and labels across auto-unarchive", as
labels: { lane: "phase-1a" },
});
const archived = await ctx.client.fetchAgent(created.id);
const archived = await ctx.client.fetchAgent({ agentId: created.id });
expect(archived).not.toBeNull();
expect(archived?.agent.archivedAt).toBeTruthy();
expect(archived?.agent.title).toBe("Pinned Title");
@@ -817,7 +817,7 @@ test("update_agent persists unloaded title and labels across auto-unarchive", as
const finalState = await ctx.client.waitForFinish(created.id, 120000);
expect(finalState.status).toBe("idle");
const unarchived = await ctx.client.fetchAgent(created.id);
const unarchived = await ctx.client.fetchAgent({ agentId: created.id });
expect(unarchived).not.toBeNull();
expect(unarchived?.agent.archivedAt).toBeNull();
expect(unarchived?.agent.title).toBe("Pinned Title");
@@ -974,7 +974,7 @@ test("creates agent and exercises lifecycle", async () => {
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
const fetchedResult = await ctx.client.fetchAgent(agent.id);
const fetchedResult = await ctx.client.fetchAgent({ agentId: agent.id });
expect(fetchedResult?.agent.id).toBe(agent.id);
const agentUpdate = await agentUpdatePromise;
@@ -1149,7 +1149,10 @@ test("creates agent and exercises lifecycle", async () => {
return unsubscribeCommands;
});
const commands = await ctx.client.listCommands(agent.id, commandsRequestId);
const commands = await ctx.client.listCommands({
agentId: agent.id,
requestId: commandsRequestId,
});
const commandsMessage = await commandsResponsePromise;
expect(commands.agentId).toBe(agent.id);
expect(Array.isArray(commands.commands)).toBe(true);
@@ -62,7 +62,7 @@ describe("timestamp behavior", () => {
direction: "tail",
limit: 200,
});
const refreshedResult = await ctx.client.fetchAgent(agent.id);
const refreshedResult = await ctx.client.fetchAgent({ agentId: agent.id });
// Verify agent is still idle
expect(refreshedResult?.agent.status).toBe("idle");
@@ -79,7 +79,7 @@ describe("timestamp behavior", () => {
direction: "tail",
limit: 200,
});
const clearResult = await ctx.client.fetchAgent(agent.id);
const clearResult = await ctx.client.fetchAgent({ agentId: agent.id });
// Timestamp should STILL not have changed
expect(clearResult?.agent.updatedAt).toBe(initialUpdatedAt);
@@ -575,7 +575,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
}
expect(sawTimelineGrowth).toBe(true);
} else {
const current = await client.fetchAgent(agent.id);
const current = await client.fetchAgent({ agentId: agent.id });
expect(current.agent.status).toBe("idle");
}
} finally {
@@ -642,7 +642,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
expect(settled.status).toBe("idle");
}
const finalResult = await client.fetchAgent(agent.id);
const finalResult = await client.fetchAgent({ agentId: agent.id });
expect(finalResult?.agent.status).toBe("idle");
} finally {
await client.close();
@@ -835,7 +835,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
try {
secondCompletion = await client.waitForFinish(agent.id, 20_000);
} catch {
const atTimeoutResult = await client.fetchAgent(agent.id);
const atTimeoutResult = await client.fetchAgent({ agentId: agent.id });
// eslint-disable-next-line no-console
console.log(
JSON.stringify({
@@ -911,7 +911,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
try {
secondCompletion = await client.waitForFinish(agent.id, 20_000);
} catch {
const atTimeoutResult = await client.fetchAgent(agent.id);
const atTimeoutResult = await client.fetchAgent({ agentId: agent.id });
// eslint-disable-next-line no-console
console.log(
JSON.stringify({
@@ -1068,7 +1068,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
error instanceof Error ? error : new Error(String(error ?? "wait_for_finish failed"));
}
let afterWaitResult = await client.fetchAgent(agent.id);
let afterWaitResult = await client.fetchAgent({ agentId: agent.id });
let cancelRecovered = true;
if (afterWaitResult?.agent.status === "running") {
await client.cancelAgent(agent.id);
@@ -1078,7 +1078,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
} catch {
cancelRecovered = false;
}
afterWaitResult = await client.fetchAgent(agent.id);
afterWaitResult = await client.fetchAgent({ agentId: agent.id });
}
expect(waitError).toBeNull();
@@ -179,7 +179,7 @@ async function runtimeSessionId(
harness: ClaudeRewindHarness,
session: ClaudeRewindSession,
): Promise<string | null> {
const snapshot = await harness.client.fetchAgent(session.agentId);
const snapshot = await harness.client.fetchAgent({ agentId: session.agentId });
return snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId ?? null;
}
@@ -289,7 +289,7 @@ describe("daemon E2E (real claude) - rewind", () => {
expect(sessionIdAfter).not.toBe(sessionIdBefore);
}
const snapshot = await harness.client.fetchAgent(session.agentId);
const snapshot = await harness.client.fetchAgent({ agentId: session.agentId });
expect(snapshot?.agent.status).toBe("idle");
expect(snapshot?.agent.pendingPermissions).toEqual([]);
} finally {
@@ -34,7 +34,7 @@ interface CodexRewindSession {
const TURN_TIMEOUT_MS = 180_000;
async function fetchThreadId(client: DaemonClient, agentId: string): Promise<string> {
const result = await client.fetchAgent(agentId);
const result = await client.fetchAgent({ agentId });
const threadId = result?.agent.persistence?.sessionId ?? result?.agent.runtimeInfo?.sessionId;
if (!threadId) {
throw new Error(`Agent ${agentId} does not have a visible Codex thread id`);
@@ -55,7 +55,7 @@ describe("daemon E2E (real claude) - runtime model reconciliation", () => {
const finish = await client.waitForFinish(agent.id, 180_000);
expect(finish.status).toBe("idle");
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
expect(snapshot).not.toBeNull();
const runtimeModelId = snapshot?.agent.runtimeInfo?.model ?? null;
expect(typeof runtimeModelId).toBe("string");
@@ -109,7 +109,8 @@ describe("daemon E2E (real opencode) - draft feature discovery", () => {
expect(await provider.listImportableSessions?.({ cwd })).toEqual([]);
await withConnectedOpenCodeDaemon(provider, async ({ client }) => {
const response = await client.listCommands("draft-opencode-agent", {
const response = await client.listCommands({
agentId: "draft-opencode-agent",
draftConfig: {
provider: "opencode",
cwd,
@@ -66,7 +66,7 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
expect(finish.status).toBe("idle");
expect(finish.lastMessage).toContain("BIG_PICKLE_OK");
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
expect(snapshot.agent?.status).toBe("idle");
const timeline = await client.fetchAgentTimeline(agent.id, {
@@ -57,7 +57,7 @@ describe("daemon E2E (real opencode) - invalid mode handling", () => {
expect(finish.status).toBe("error");
expect(finish.error).toBeTruthy();
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
expect(snapshot.agent?.status).toBe("error");
} finally {
await client.close().catch(() => undefined);
@@ -57,7 +57,7 @@ describe("daemon E2E (real opencode) - invalid model handling", () => {
expect(finish.status).toBe("error");
expect(finish.error).toBeTruthy();
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
expect(snapshot.agent?.status).toBe("error");
} finally {
await client.close().catch(() => undefined);
@@ -69,7 +69,7 @@ async function runtimeSessionId(
harness: OpenCodeRewindHarness,
session: OpenCodeRewindSession,
): Promise<string> {
const snapshot = await harness.client.fetchAgent(session.agentId);
const snapshot = await harness.client.fetchAgent({ agentId: session.agentId });
const sessionId =
snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId;
if (!sessionId) {
@@ -107,7 +107,7 @@ async function recordAgentStatusTransitions(
session: OpenCodeRewindSession,
): Promise<{ records: AgentStatusRecord[]; stop: () => void }> {
const records: AgentStatusRecord[] = [];
const initial = await harness.client.fetchAgent(session.agentId);
const initial = await harness.client.fetchAgent({ agentId: session.agentId });
if (initial?.agent.status) {
records.push({ at: Date.now(), status: initial.agent.status });
}
@@ -143,7 +143,7 @@ async function expectAgentIdle(
harness: OpenCodeRewindHarness,
session: OpenCodeRewindSession,
): Promise<void> {
const snapshot = await harness.client.fetchAgent(session.agentId);
const snapshot = await harness.client.fetchAgent({ agentId: session.agentId });
expect(snapshot?.agent.status).toBe("idle");
}
@@ -100,7 +100,7 @@ async function approvePendingPermissions(
agentId: string,
handledPermissionIds: Set<string>,
): Promise<void> {
const snapshot = await client.fetchAgent(agentId).catch(() => null);
const snapshot = await client.fetchAgent({ agentId }).catch(() => null);
const pending = snapshot?.agent.pendingPermissions ?? [];
const toApprove = pending.filter((permission) => !handledPermissionIds.has(permission.id));
for (const permission of toApprove) {
@@ -50,7 +50,7 @@ async function runtimeSessionId(
harness: PiRewindHarness,
session: PiRewindSession,
): Promise<string> {
const snapshot = await harness.client.fetchAgent(session.agentId);
const snapshot = await harness.client.fetchAgent({ agentId: session.agentId });
const sessionId =
snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId;
if (!sessionId) {
@@ -149,7 +149,7 @@ test(
model: PI_FREE_COMPACTION_TEST_MODEL,
});
const result = await client.listCommands(agent.id);
const result = await client.listCommands({ agentId: agent.id });
expect(result.commands).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -244,7 +244,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => {
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
if (finish.status !== "idle") {
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
throw new Error(
`Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(postSendAssistantTexts)} turnStarted=${countTurnStarted(postSendMessages, agent.id)} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`,
);
@@ -204,7 +204,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => {
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
if (finish.status !== "idle") {
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
throw new Error(
`Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(getAssistantTexts(postSendMessages, agent.id))} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`,
);
@@ -278,7 +278,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => {
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
if (finish.status !== "idle") {
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
throw new Error(
`Expected idle after quick follow-up, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(getAssistantTexts(postSendMessages, agent.id))} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`,
);
@@ -114,7 +114,7 @@ describe("daemon E2E (real claude) - send while running recovery", () => {
}
await secondary.waitForFinish(agent.id, 180_000);
const finalResult = await secondary.fetchAgent(agent.id);
const finalResult = await secondary.fetchAgent({ agentId: agent.id });
if (finalResult) {
applySnapshot(finalResult.agent);
}
@@ -111,7 +111,7 @@ describe("daemon E2E (real codex) - send while running recovery", () => {
}
await secondary.waitForFinish(agent.id, 120_000);
const finalResult = await secondary.fetchAgent(agent.id);
const finalResult = await secondary.fetchAgent({ agentId: agent.id });
if (finalResult) {
applySnapshot(finalResult.agent);
}
@@ -513,7 +513,7 @@ test("creating another same-cwd local workspace keeps running status on the owni
throw new Error(third.error ?? "Expected third workspace to be created");
}
expect((await client.fetchAgent(agent.id))?.agent.status).toBe("running");
expect((await client.fetchAgent({ agentId: agent.id }))?.agent.status).toBe("running");
expect(await statusByWorkspaceId(client)).toEqual(
new Map([
[firstWorkspaceId, "running"],
@@ -565,7 +565,7 @@ test("two workspaces sharing one cwd compute agent status per workspaceId", asyn
);
await waitForPermission(client, agentA.id);
const fetchedA = await client.fetchAgent(agentA.id);
const fetchedA = await client.fetchAgent({ agentId: agentA.id });
expect(fetchedA?.agent.workspaceId).toBe(WORKSPACE_A);
expect(await statusByWorkspaceId(client)).toEqual(
@@ -609,7 +609,7 @@ test("two workspaces sharing one cwd compute agent status per workspaceId", asyn
);
await waitForPermission(client, agentB.id);
const fetchedB = await client.fetchAgent(agentB.id);
const fetchedB = await client.fetchAgent({ agentId: agentB.id });
expect(fetchedB?.agent.workspaceId).toBe(WORKSPACE_B);
expect(await statusByWorkspaceId(client)).toEqual(