From b613bea9f6704e7398a24240f5c890aa54efa2e7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 29 Jun 2026 17:23:18 +0200 Subject: [PATCH] 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 --- docs/architecture.md | 2 + docs/timeline-sync.md | 2 + packages/app/e2e/helpers/archive-tab.ts | 6 +- packages/app/e2e/helpers/seed-client.ts | 6 +- .../src/agent-stream/strategy-web.test.tsx | 207 ++++++ .../app/src/agent-stream/strategy-web.tsx | 9 +- .../src/app/h/[serverId]/agent/[agentId].tsx | 2 +- packages/app/src/contexts/session-context.tsx | 11 + .../hooks/use-agent-commands-query.test.ts | 14 +- .../app/src/hooks/use-agent-commands-query.ts | 12 +- .../hooks/use-agent-initialization.test.ts | 39 +- .../app/src/hooks/use-agent-initialization.ts | 32 +- packages/app/src/panels/agent-panel.tsx | 4 +- .../app/src/utils/agent-initialization.ts | 13 +- packages/cli/src/commands/agent/attach.ts | 8 +- packages/cli/src/commands/agent/delete.ts | 2 +- packages/cli/src/commands/agent/inspect.ts | 2 +- packages/cli/src/commands/agent/logs.ts | 19 +- packages/cli/src/commands/agent/mode.ts | 2 +- packages/cli/src/commands/agent/stop.ts | 2 +- packages/cli/src/commands/agent/update.ts | 4 +- packages/cli/src/commands/agent/wait.ts | 5 +- packages/cli/src/commands/chat/shared.ts | 16 +- packages/cli/src/commands/chat/wait.ts | 20 +- .../cli/src/commands/daemon/local-daemon.ts | 12 +- packages/cli/src/commands/daemon/pair.ts | 6 +- packages/cli/src/commands/daemon/status.ts | 11 +- packages/cli/src/commands/onboard.ts | 40 +- packages/cli/src/commands/permit/allow.ts | 2 +- packages/cli/src/commands/permit/deny.ts | 2 +- packages/cli/src/utils/timeline.ts | 4 + packages/client/src/daemon-client.test.ts | 648 +++++++++++++++++- packages/client/src/daemon-client.ts | 247 +++---- packages/client/src/index.ts | 2 +- .../claude/agent-commands.e2e.test.ts | 4 +- .../codex-mcp-agent-commands.e2e.test.ts | 4 +- .../opencode-agent-commands.e2e.test.ts | 4 +- ...code-agent-custom-command.real.e2e.test.ts | 2 +- .../cli-run-workspace-precedence.e2e.test.ts | 2 +- .../src/server/daemon-client.e2e.test.ts | 25 +- .../daemon-e2e/agent-operations.e2e.test.ts | 4 +- .../claude-autonomous-wake.real.e2e.test.ts | 12 +- .../daemon-e2e/claude-rewind.real.e2e.test.ts | 4 +- .../daemon-e2e/codex-rewind.real.e2e.test.ts | 2 +- ...-runtime-reconcile-claude.real.e2e.test.ts | 2 +- .../opencode-draft-features.real.e2e.test.ts | 3 +- ...ncode-initial-prompt-wait.real.e2e.test.ts | 2 +- .../opencode-invalid-mode.real.e2e.test.ts | 2 +- .../opencode-invalid-model.real.e2e.test.ts | 2 +- .../opencode-rewind.real.e2e.test.ts | 6 +- .../opencode-send-interrupt.real.e2e.test.ts | 2 +- .../daemon-e2e/pi-rewind.real.e2e.test.ts | 2 +- .../src/server/daemon-e2e/pi.real.e2e.test.ts | 2 +- ...d-during-tool-call-claude.real.e2e.test.ts | 2 +- ...nd-during-tool-call-codex.real.e2e.test.ts | 4 +- ...hile-running-stuck-claude.real.e2e.test.ts | 2 +- .../send-while-running-stuck.real.e2e.test.ts | 2 +- .../workspace-same-cwd-isolation.e2e.test.ts | 6 +- 58 files changed, 1234 insertions(+), 281 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 848131416..ab95f6849 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -169,6 +169,8 @@ There is no dedicated welcome message; the server emits a `status` session messa Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead. +Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default. + New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names. **Notable session message types:** diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 2d85dfaf2..f2b9bf6b1 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -28,6 +28,8 @@ Page limits are projected-item targets. A tool call lifecycle is one projected i When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`. +Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog. + The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward. ## Resume behavior diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index 869bc6561..768c6c6bf 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -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 { - const result = await client.fetchAgent(agentId); + const result = await client.fetchAgent({ agentId }); return result?.agent.archivedAt ?? null; } diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index 5c3e30bcb..1f2ab0447 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -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; diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index ff8b69788..eb57d3064 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -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(); + 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(); + 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(); + }); }); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index a5d352659..15b552039 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -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); } diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 9763d7168..180e588ef 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -84,7 +84,7 @@ function HostAgentReadyRouteContent() { let cancelled = false; void client - .fetchAgent(agentId) + .fetchAgent({ agentId }) .then((result) => { if (cancelled || redirectedRef.current) { return; diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index b196b4afe..6f5980f74 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -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, diff --git a/packages/app/src/hooks/use-agent-commands-query.test.ts b/packages/app/src/hooks/use-agent-commands-query.test.ts index dfff07095..87f5919da 100644 --- a/packages/app/src/hooks/use-agent-commands-query.test.ts +++ b/packages/app/src/hooks/use-agent-commands-query.test.ts @@ -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>; 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[0]) => { + calls.push({ agentId: options.agentId, draftConfig: options.draftConfig }); return response; }) as ListCommands, }; diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index 3df32043d..22afaed3e 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -16,14 +16,22 @@ export interface AgentSlashCommand { export type DraftCommandConfig = AgentCommandsDraftConfig; -export type AgentCommandsClient = Pick; +interface ListAgentCommandsOptions { + agentId: string; + draftConfig?: DraftCommandConfig; +} + +export interface AgentCommandsClient { + listCommands(options: ListAgentCommandsOptions): ReturnType; +} export async function fetchAgentCommands(input: { client: AgentCommandsClient; agentId: string; draftConfig?: DraftCommandConfig; }): Promise { - 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[]; diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index 850d5ce00..2ef638caf 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -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", () => { diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index f37e4622b..b9a76bc99 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -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); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 6cb47587a..b34b7f57a 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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; } diff --git a/packages/app/src/utils/agent-initialization.ts b/packages/app/src/utils/agent-initialization.ts index 9b73f7373..0d0da8e4d 100644 --- a/packages/app/src/utils/agent-initialization.ts +++ b/packages/app/src/utils/agent-initialization.ts @@ -7,6 +7,7 @@ export interface DeferredInit { } const initPromises = new Map(); +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): 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; } diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index 6c726eef5..fb58f71fa 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -6,7 +6,10 @@ export function addAttachOptions(cmd: Command): Command { .argument("", "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); diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts index 159ea941e..dafb26e4c 100644 --- a/packages/cli/src/commands/agent/delete.ts +++ b/packages/cli/src/commands/agent/delete.ts @@ -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", diff --git a/packages/cli/src/commands/agent/inspect.ts b/packages/cli/src/commands/agent/inspect.ts index f1a0c919f..93f1da3d4 100644 --- a/packages/cli/src/commands/agent/inspect.ts +++ b/packages/cli/src/commands/agent/inspect.ts @@ -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", diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index f2b414a42..14355aee8 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -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 { - 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) { diff --git a/packages/cli/src/commands/agent/mode.ts b/packages/cli/src/commands/agent/mode.ts index 1cd25904c..46229b580 100644 --- a/packages/cli/src/commands/agent/mode.ts +++ b/packages/cli/src/commands/agent/mode.ts @@ -65,7 +65,7 @@ export async function runModeCommand( let client: Awaited> | 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", diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts index 23bcda19d..42d269fcf 100644 --- a/packages/cli/src/commands/agent/stop.ts +++ b/packages/cli/src/commands/agent/stop.ts @@ -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", diff --git a/packages/cli/src/commands/agent/update.ts b/packages/cli/src/commands/agent/update.ts index b531fadf6..ee868afff 100644 --- a/packages/cli/src/commands/agent/update.ts +++ b/packages/cli/src/commands/agent/update.ts @@ -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}`); } diff --git a/packages/cli/src/commands/agent/wait.ts b/packages/cli/src/commands/agent/wait.ts index 9db46dc19..6c800746c 100644 --- a/packages/cli/src/commands/agent/wait.ts +++ b/packages/cli/src/commands/agent/wait.ts @@ -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 { 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; diff --git a/packages/cli/src/commands/chat/shared.ts b/packages/cli/src/commands/chat/shared.ts index 3254ff2b7..52ea27096 100644 --- a/packages/cli/src/commands/chat/shared.ts +++ b/packages/cli/src/commands/chat/shared.ts @@ -26,6 +26,7 @@ export async function connectChatClient(host?: string) { export async function attachAgentNamesToMessages( client: Awaited>, messages: ChatMessageRow[], + options: { timeout?: number; bestEffort?: boolean } = {}, ): Promise { const agentIds = new Set(); 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>; + 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(); for (const entry of payload.entries) { const title = entry.agent.title?.trim(); diff --git a/packages/cli/src/commands/chat/wait.ts b/packages/cli/src/commands/chat/wait.ts index 3c8d38ee6..3db53c897 100644 --- a/packages/cli/src/commands/chat/wait.ts +++ b/packages/cli/src/commands/chat/wait.ts @@ -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> { + 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", diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index f5c17b4ae..7423c275c 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -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, }); diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index cd238fce7..a30f0e2cc 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -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 ", "Paseo home directory (default: ~/.paseo)") @@ -35,7 +37,9 @@ export async function runPairCommand(options: PairOptions): Promise { 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 }, diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index baea163d5..075c6f005 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -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, diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index b581509ec..2600bdb29 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -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 { +async function probeDaemonReady(home: string, timeoutMs: number): Promise { 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); diff --git a/packages/cli/src/commands/permit/allow.ts b/packages/cli/src/commands/permit/allow.ts index d3c4b484d..15234c0d0 100644 --- a/packages/cli/src/commands/permit/allow.ts +++ b/packages/cli/src/commands/permit/allow.ts @@ -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 = { diff --git a/packages/cli/src/commands/permit/deny.ts b/packages/cli/src/commands/permit/deny.ts index bb37537c9..94ffe9d57 100644 --- a/packages/cli/src/commands/permit/deny.ts +++ b/packages/cli/src/commands/permit/deny.ts @@ -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 = { diff --git a/packages/cli/src/utils/timeline.ts b/packages/cli/src/utils/timeline.ts index 77e4d3142..7e84757da 100644 --- a/packages/cli/src/utils/timeline.ts +++ b/packages/cli/src/utils/timeline.ts @@ -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); } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 8550c1b9d..d0cd496d4 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -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", }); }); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 005cfd2e3..9a317540b 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -374,9 +374,11 @@ export interface WriteProjectConfigInput { requestId?: string; } interface ListCommandsOptions { + agentId: string; requestId?: string; draftConfig?: ListCommandsDraftConfig; } +type LegacyListCommandsOptions = Omit; 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; +export interface FetchAgentOptions { + agentId: string; + requestId?: string; + timeout?: number; +} +type LegacyFetchAgentOptions = Omit; 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; type RestartRequestedStatusPayload = z.infer; type ShutdownRequestedStatusPayload = z.infer; +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; type FetchAgentsPayload = Extract< SessionOutboundMessage, @@ -477,6 +526,7 @@ type FetchAgentsPayload = Extract< type FetchAgentsRequest = Extract; export type FetchAgentsOptions = Omit & { 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(params: { requestId: string; message: SessionInboundMessage; - timeout: number; + timeout?: number; select: (msg: SessionOutboundMessage) => T | null; options?: { skipQueue?: boolean }; }): Promise { + const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS; const { promise, cancel } = this.waitForWithCancel>( (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) => TResult | null; @@ -1503,7 +1556,7 @@ export class DaemonClient { requestId?: string; message: { type: SessionInboundMessage["type"] } & Record; responseType: TResponseType; - timeout: number; + timeout?: number; selectPayload?: (payload: CorrelatedResponsePayload) => TResult | null; }): Promise { const resolvedRequestId = this.createRequestId(params.requestId); @@ -1530,7 +1583,7 @@ export class DaemonClient { string, unknown >; - timeout: number; + timeout?: number; selectPayload?: (payload: CorrelatedResponsePayload) => TResult | null; }): Promise { 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 { - const resolvedRequestId = this.createRequestId(requestId); + async fetchAgent(options: FetchAgentOptions): Promise; + async fetchAgent(agentId: string, requestId?: string): Promise; + async fetchAgent( + agentId: string, + options?: LegacyFetchAgentOptions, + ): Promise; + async fetchAgent( + input: FetchAgentOptions | string, + legacyOptions?: LegacyFetchAgentOptions | string, + ): Promise { + 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 { - const resolvedRequestId = this.createRequestId(requestId); + async shutdownServer(options?: ShutdownServerOptions): Promise { + 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 { + async getDaemonStatus(options?: DaemonStatusOptions): Promise { 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 { + async getDaemonPairingOffer( + options?: DaemonPairingOfferOptions, + ): Promise { 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; async listCommands(agentId: string, requestId?: string): Promise; - async listCommands(agentId: string, options?: ListCommandsOptions): Promise; async listCommands( agentId: string, - requestIdOrOptions?: string | ListCommandsOptions, + options?: LegacyListCommandsOptions, + ): Promise; + async listCommands( + input: ListCommandsOptions | string, + legacyOptions?: LegacyListCommandsOptions | string, ): Promise { - 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 { - 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((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, }); } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 5faa0bf9d..c7018eb3f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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; }, diff --git a/packages/server/src/server/agent/providers/claude/agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent-commands.e2e.test.ts index 39a07a6f0..13727dc49 100644 --- a/packages/server/src/server/agent/providers/claude/agent-commands.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent-commands.e2e.test.ts @@ -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"); diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/codex-mcp-agent-commands.e2e.test.ts index 5d3e7779b..81b5c9b86 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent-commands.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent-commands.e2e.test.ts @@ -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"); diff --git a/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts index 4edaaaad6..f712c6034 100644 --- a/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts @@ -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"); diff --git a/packages/server/src/server/agent/providers/opencode-agent-custom-command.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-custom-command.real.e2e.test.ts index 1d1874370..433db9f30 100644 --- a/packages/server/src/server/agent/providers/opencode-agent-custom-command.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent-custom-command.real.e2e.test.ts @@ -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([ diff --git a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts index 2f42d69dd..b99295e46 100644 --- a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts +++ b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts @@ -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 diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 5caabdf37..e723ab892 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -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); diff --git a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts index 65933bd70..c5618b9f0 100644 --- a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts @@ -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); diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts index 83cbc0521..fe482d00a 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts @@ -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(); diff --git a/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts index 553591f24..c362529e2 100644 --- a/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts @@ -179,7 +179,7 @@ async function runtimeSessionId( harness: ClaudeRewindHarness, session: ClaudeRewindSession, ): Promise { - 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 { diff --git a/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts index a3c403105..e61346a6a 100644 --- a/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts @@ -34,7 +34,7 @@ interface CodexRewindSession { const TURN_TIMEOUT_MS = 180_000; async function fetchThreadId(client: DaemonClient, agentId: string): Promise { - 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`); diff --git a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts index cecbeebe9..83fe12e1e 100644 --- a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts @@ -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"); diff --git a/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts index ac4df109e..d66ca028e 100644 --- a/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts @@ -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, diff --git a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts index 585194628..9fa2f13cf 100644 --- a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts @@ -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, { diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts index aa67c0a32..8235937f6 100644 --- a/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts @@ -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); diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts index 4796f0423..455d09908 100644 --- a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts @@ -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); diff --git a/packages/server/src/server/daemon-e2e/opencode-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-rewind.real.e2e.test.ts index 995da59ef..81934b463 100644 --- a/packages/server/src/server/daemon-e2e/opencode-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-rewind.real.e2e.test.ts @@ -69,7 +69,7 @@ async function runtimeSessionId( harness: OpenCodeRewindHarness, session: OpenCodeRewindSession, ): Promise { - 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 { - const snapshot = await harness.client.fetchAgent(session.agentId); + const snapshot = await harness.client.fetchAgent({ agentId: session.agentId }); expect(snapshot?.agent.status).toBe("idle"); } diff --git a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts index 64e0ecdd8..bdf5012f6 100644 --- a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts @@ -100,7 +100,7 @@ async function approvePendingPermissions( agentId: string, handledPermissionIds: Set, ): Promise { - 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) { diff --git a/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts index 848e0fec8..e013328a1 100644 --- a/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts @@ -50,7 +50,7 @@ async function runtimeSessionId( harness: PiRewindHarness, session: PiRewindSession, ): Promise { - 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) { diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index 949f976bb..881ef6fbe 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -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({ diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts index 86e725add..568bf454b 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -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))}`, ); diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts index 5fd5d99ba..e122e846c 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts @@ -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))}`, ); diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts index 1d14fc737..f9f6eb6af 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts @@ -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); } diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts index 1ed3f52f0..aa75a77ee 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts @@ -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); } diff --git a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts index f49573bf3..58bf79c66 100644 --- a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts +++ b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts @@ -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(