Default client RPC waits to 60 seconds (#1789)

* fix(client): wait longer for session responses

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

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

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

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

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

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

* fix(client): respect caller timeout budgets

* fix(cli): keep diagnostic probes responsive

* Refactor daemon client request options

* Preserve daemon client legacy overloads
This commit is contained in:
Mohamed Boudra
2026-06-29 17:23:18 +02:00
committed by GitHub
parent 57800a0f17
commit b613bea9f6
58 changed files with 1234 additions and 281 deletions
@@ -28,7 +28,7 @@ describe("claude agent commands E2E", () => {
expect(agent.status).toBe("idle");
// List commands
const result = await ctx.client.listCommands(agent.id);
const result = await ctx.client.listCommands({ agentId: agent.id });
// Should have no error
expect(result.error).toBeNull();
@@ -52,7 +52,7 @@ describe("claude agent commands E2E", () => {
}, 60000);
test("returns error for non-existent agent", async () => {
const result = await ctx.client.listCommands("non-existent-agent-id");
const result = await ctx.client.listCommands({ agentId: "non-existent-agent-id" });
expect(result.error).toBeTruthy();
expect(result.error).toContain("Agent not found");
@@ -67,7 +67,7 @@ describe("codex agent commands E2E", () => {
expect(agent.provider).toBe("codex");
expect(agent.status).toBe("idle");
const result = await ctx.client.listCommands(agent.id);
const result = await ctx.client.listCommands({ agentId: agent.id });
expect(result.error).toBeNull();
expect(result.commands.length).toBeGreaterThan(0);
@@ -177,7 +177,7 @@ describe("codex agent commands E2E", () => {
}, 30_000);
test("returns error for non-existent agent", async () => {
const result = await ctx.client.listCommands("non-existent-agent-id");
const result = await ctx.client.listCommands({ agentId: "non-existent-agent-id" });
expect(result.error).toBeTruthy();
expect(result.error).toContain("Agent not found");
@@ -24,7 +24,7 @@ describe("opencode agent commands E2E", () => {
expect(agent.provider).toBe("opencode");
expect(agent.status).toBe("idle");
const result = await ctx.client.listCommands(agent.id);
const result = await ctx.client.listCommands({ agentId: agent.id });
expect(result.error).toBeNull();
expect(result.commands.length).toBeGreaterThan(0);
@@ -87,7 +87,7 @@ describe("opencode agent commands E2E", () => {
}, 30_000);
test("returns error for non-existent agent", async () => {
const result = await ctx.client.listCommands("non-existent-agent-id");
const result = await ctx.client.listCommands({ agentId: "non-existent-agent-id" });
expect(result.error).toBeTruthy();
expect(result.error).toContain("Agent not found");
@@ -77,7 +77,7 @@ describe("opencode custom command Big Pickle E2E (real)", () => {
expect(agent.model).toBe(OPENCODE_REAL_TEST_MODEL);
expect(agent.status).toBe("idle");
const commands = await ctx.client.listCommands(agent.id);
const commands = await ctx.client.listCommands({ agentId: agent.id });
expect(commands.error).toBeNull();
expect(commands.commands).toEqual(
expect.arrayContaining([
@@ -53,7 +53,7 @@ test("daemon mints a distinct local workspace per run and stamps agents by id",
expect(firstAgent.workspaceId).toBe(firstWorkspaceId);
expect(await workspaceIds(client)).toContain(firstWorkspaceId);
const fetchedFirst = await client.fetchAgent(firstAgent.id);
const fetchedFirst = await client.fetchAgent({ agentId: firstAgent.id });
expect(fetchedFirst?.agent.workspaceId).toBe(firstWorkspaceId);
// A second bare run in the SAME cwd mints a DISTINCT workspace; each run
@@ -197,12 +197,12 @@ test("createAgent with background initialPrompt returns a running snapshot befor
expect(agent.status).toBe("running");
const fetchedWhileRunning = await client.fetchAgent(agent.id);
const fetchedWhileRunning = await client.fetchAgent({ agentId: agent.id });
expect(fetchedWhileRunning?.agent.status).toBe("running");
await new Promise((resolve) => setTimeout(resolve, 350));
const fetchedAfterCompletion = await client.fetchAgent(agent.id);
const fetchedAfterCompletion = await client.fetchAgent({ agentId: agent.id });
expect(fetchedAfterCompletion?.agent.status).toBe("idle");
} finally {
await client.close();
@@ -638,7 +638,7 @@ test("interrupts a running agent before archiving", async () => {
const result = await ctx.client.archiveAgent(created.id);
expect(result.archivedAt).toBeTruthy();
const archivedResult = await ctx.client.fetchAgent(created.id);
const archivedResult = await ctx.client.fetchAgent({ agentId: created.id });
expect(archivedResult).not.toBeNull();
expect(archivedResult?.agent.archivedAt).toBeTruthy();
expect(archivedResult?.agent.status).not.toBe("running");
@@ -671,7 +671,7 @@ test("send_agent_message auto-unarchives archived agents", async () => {
const finalState = await ctx.client.waitForFinish(created.id, 120000);
expect(finalState.status).toBe("idle");
const refreshed = await ctx.client.fetchAgent(created.id);
const refreshed = await ctx.client.fetchAgent({ agentId: created.id });
expect(refreshed).not.toBeNull();
expect(refreshed?.agent.archivedAt).toBeNull();
} finally {
@@ -691,7 +691,7 @@ test("refresh_agent auto-unarchives archived agents", async () => {
await ctx.client.archiveAgent(created.id);
await ctx.client.refreshAgent(created.id);
const refreshed = await ctx.client.fetchAgent(created.id);
const refreshed = await ctx.client.fetchAgent({ agentId: created.id });
expect(refreshed).not.toBeNull();
expect(refreshed?.agent.archivedAt).toBeNull();
} finally {
@@ -770,7 +770,7 @@ test("resume_agent auto-unarchives archived agents", async () => {
cwd,
},
});
const agentBeforeArchive = await ctx.client.fetchAgent(created.id);
const agentBeforeArchive = await ctx.client.fetchAgent({ agentId: created.id });
expect(agentBeforeArchive?.agent.persistence).toBeTruthy();
await ctx.client.archiveAgent(created.id);
@@ -779,7 +779,7 @@ test("resume_agent auto-unarchives archived agents", async () => {
throw new Error("Expected persistence handle for resume test");
}
const resumed = await ctx.client.resumeAgent(handle);
const resumedDetails = await ctx.client.fetchAgent(resumed.id);
const resumedDetails = await ctx.client.fetchAgent({ agentId: resumed.id });
expect(resumedDetails).not.toBeNull();
expect(resumedDetails?.agent.archivedAt).toBeNull();
@@ -807,7 +807,7 @@ test("update_agent persists unloaded title and labels across auto-unarchive", as
labels: { lane: "phase-1a" },
});
const archived = await ctx.client.fetchAgent(created.id);
const archived = await ctx.client.fetchAgent({ agentId: created.id });
expect(archived).not.toBeNull();
expect(archived?.agent.archivedAt).toBeTruthy();
expect(archived?.agent.title).toBe("Pinned Title");
@@ -817,7 +817,7 @@ test("update_agent persists unloaded title and labels across auto-unarchive", as
const finalState = await ctx.client.waitForFinish(created.id, 120000);
expect(finalState.status).toBe("idle");
const unarchived = await ctx.client.fetchAgent(created.id);
const unarchived = await ctx.client.fetchAgent({ agentId: created.id });
expect(unarchived).not.toBeNull();
expect(unarchived?.agent.archivedAt).toBeNull();
expect(unarchived?.agent.title).toBe("Pinned Title");
@@ -974,7 +974,7 @@ test("creates agent and exercises lifecycle", async () => {
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
const fetchedResult = await ctx.client.fetchAgent(agent.id);
const fetchedResult = await ctx.client.fetchAgent({ agentId: agent.id });
expect(fetchedResult?.agent.id).toBe(agent.id);
const agentUpdate = await agentUpdatePromise;
@@ -1149,7 +1149,10 @@ test("creates agent and exercises lifecycle", async () => {
return unsubscribeCommands;
});
const commands = await ctx.client.listCommands(agent.id, commandsRequestId);
const commands = await ctx.client.listCommands({
agentId: agent.id,
requestId: commandsRequestId,
});
const commandsMessage = await commandsResponsePromise;
expect(commands.agentId).toBe(agent.id);
expect(Array.isArray(commands.commands)).toBe(true);
@@ -62,7 +62,7 @@ describe("timestamp behavior", () => {
direction: "tail",
limit: 200,
});
const refreshedResult = await ctx.client.fetchAgent(agent.id);
const refreshedResult = await ctx.client.fetchAgent({ agentId: agent.id });
// Verify agent is still idle
expect(refreshedResult?.agent.status).toBe("idle");
@@ -79,7 +79,7 @@ describe("timestamp behavior", () => {
direction: "tail",
limit: 200,
});
const clearResult = await ctx.client.fetchAgent(agent.id);
const clearResult = await ctx.client.fetchAgent({ agentId: agent.id });
// Timestamp should STILL not have changed
expect(clearResult?.agent.updatedAt).toBe(initialUpdatedAt);
@@ -575,7 +575,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
}
expect(sawTimelineGrowth).toBe(true);
} else {
const current = await client.fetchAgent(agent.id);
const current = await client.fetchAgent({ agentId: agent.id });
expect(current.agent.status).toBe("idle");
}
} finally {
@@ -642,7 +642,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
expect(settled.status).toBe("idle");
}
const finalResult = await client.fetchAgent(agent.id);
const finalResult = await client.fetchAgent({ agentId: agent.id });
expect(finalResult?.agent.status).toBe("idle");
} finally {
await client.close();
@@ -835,7 +835,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
try {
secondCompletion = await client.waitForFinish(agent.id, 20_000);
} catch {
const atTimeoutResult = await client.fetchAgent(agent.id);
const atTimeoutResult = await client.fetchAgent({ agentId: agent.id });
// eslint-disable-next-line no-console
console.log(
JSON.stringify({
@@ -911,7 +911,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
try {
secondCompletion = await client.waitForFinish(agent.id, 20_000);
} catch {
const atTimeoutResult = await client.fetchAgent(agent.id);
const atTimeoutResult = await client.fetchAgent({ agentId: agent.id });
// eslint-disable-next-line no-console
console.log(
JSON.stringify({
@@ -1068,7 +1068,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
error instanceof Error ? error : new Error(String(error ?? "wait_for_finish failed"));
}
let afterWaitResult = await client.fetchAgent(agent.id);
let afterWaitResult = await client.fetchAgent({ agentId: agent.id });
let cancelRecovered = true;
if (afterWaitResult?.agent.status === "running") {
await client.cancelAgent(agent.id);
@@ -1078,7 +1078,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
} catch {
cancelRecovered = false;
}
afterWaitResult = await client.fetchAgent(agent.id);
afterWaitResult = await client.fetchAgent({ agentId: agent.id });
}
expect(waitError).toBeNull();
@@ -179,7 +179,7 @@ async function runtimeSessionId(
harness: ClaudeRewindHarness,
session: ClaudeRewindSession,
): Promise<string | null> {
const snapshot = await harness.client.fetchAgent(session.agentId);
const snapshot = await harness.client.fetchAgent({ agentId: session.agentId });
return snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId ?? null;
}
@@ -289,7 +289,7 @@ describe("daemon E2E (real claude) - rewind", () => {
expect(sessionIdAfter).not.toBe(sessionIdBefore);
}
const snapshot = await harness.client.fetchAgent(session.agentId);
const snapshot = await harness.client.fetchAgent({ agentId: session.agentId });
expect(snapshot?.agent.status).toBe("idle");
expect(snapshot?.agent.pendingPermissions).toEqual([]);
} finally {
@@ -34,7 +34,7 @@ interface CodexRewindSession {
const TURN_TIMEOUT_MS = 180_000;
async function fetchThreadId(client: DaemonClient, agentId: string): Promise<string> {
const result = await client.fetchAgent(agentId);
const result = await client.fetchAgent({ agentId });
const threadId = result?.agent.persistence?.sessionId ?? result?.agent.runtimeInfo?.sessionId;
if (!threadId) {
throw new Error(`Agent ${agentId} does not have a visible Codex thread id`);
@@ -55,7 +55,7 @@ describe("daemon E2E (real claude) - runtime model reconciliation", () => {
const finish = await client.waitForFinish(agent.id, 180_000);
expect(finish.status).toBe("idle");
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
expect(snapshot).not.toBeNull();
const runtimeModelId = snapshot?.agent.runtimeInfo?.model ?? null;
expect(typeof runtimeModelId).toBe("string");
@@ -109,7 +109,8 @@ describe("daemon E2E (real opencode) - draft feature discovery", () => {
expect(await provider.listImportableSessions?.({ cwd })).toEqual([]);
await withConnectedOpenCodeDaemon(provider, async ({ client }) => {
const response = await client.listCommands("draft-opencode-agent", {
const response = await client.listCommands({
agentId: "draft-opencode-agent",
draftConfig: {
provider: "opencode",
cwd,
@@ -66,7 +66,7 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
expect(finish.status).toBe("idle");
expect(finish.lastMessage).toContain("BIG_PICKLE_OK");
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
expect(snapshot.agent?.status).toBe("idle");
const timeline = await client.fetchAgentTimeline(agent.id, {
@@ -57,7 +57,7 @@ describe("daemon E2E (real opencode) - invalid mode handling", () => {
expect(finish.status).toBe("error");
expect(finish.error).toBeTruthy();
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
expect(snapshot.agent?.status).toBe("error");
} finally {
await client.close().catch(() => undefined);
@@ -57,7 +57,7 @@ describe("daemon E2E (real opencode) - invalid model handling", () => {
expect(finish.status).toBe("error");
expect(finish.error).toBeTruthy();
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
expect(snapshot.agent?.status).toBe("error");
} finally {
await client.close().catch(() => undefined);
@@ -69,7 +69,7 @@ async function runtimeSessionId(
harness: OpenCodeRewindHarness,
session: OpenCodeRewindSession,
): Promise<string> {
const snapshot = await harness.client.fetchAgent(session.agentId);
const snapshot = await harness.client.fetchAgent({ agentId: session.agentId });
const sessionId =
snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId;
if (!sessionId) {
@@ -107,7 +107,7 @@ async function recordAgentStatusTransitions(
session: OpenCodeRewindSession,
): Promise<{ records: AgentStatusRecord[]; stop: () => void }> {
const records: AgentStatusRecord[] = [];
const initial = await harness.client.fetchAgent(session.agentId);
const initial = await harness.client.fetchAgent({ agentId: session.agentId });
if (initial?.agent.status) {
records.push({ at: Date.now(), status: initial.agent.status });
}
@@ -143,7 +143,7 @@ async function expectAgentIdle(
harness: OpenCodeRewindHarness,
session: OpenCodeRewindSession,
): Promise<void> {
const snapshot = await harness.client.fetchAgent(session.agentId);
const snapshot = await harness.client.fetchAgent({ agentId: session.agentId });
expect(snapshot?.agent.status).toBe("idle");
}
@@ -100,7 +100,7 @@ async function approvePendingPermissions(
agentId: string,
handledPermissionIds: Set<string>,
): Promise<void> {
const snapshot = await client.fetchAgent(agentId).catch(() => null);
const snapshot = await client.fetchAgent({ agentId }).catch(() => null);
const pending = snapshot?.agent.pendingPermissions ?? [];
const toApprove = pending.filter((permission) => !handledPermissionIds.has(permission.id));
for (const permission of toApprove) {
@@ -50,7 +50,7 @@ async function runtimeSessionId(
harness: PiRewindHarness,
session: PiRewindSession,
): Promise<string> {
const snapshot = await harness.client.fetchAgent(session.agentId);
const snapshot = await harness.client.fetchAgent({ agentId: session.agentId });
const sessionId =
snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId;
if (!sessionId) {
@@ -149,7 +149,7 @@ test(
model: PI_FREE_COMPACTION_TEST_MODEL,
});
const result = await client.listCommands(agent.id);
const result = await client.listCommands({ agentId: agent.id });
expect(result.commands).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -244,7 +244,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => {
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
if (finish.status !== "idle") {
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
throw new Error(
`Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(postSendAssistantTexts)} turnStarted=${countTurnStarted(postSendMessages, agent.id)} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`,
);
@@ -204,7 +204,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => {
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
if (finish.status !== "idle") {
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
throw new Error(
`Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(getAssistantTexts(postSendMessages, agent.id))} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`,
);
@@ -278,7 +278,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => {
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
if (finish.status !== "idle") {
const snapshot = await client.fetchAgent(agent.id);
const snapshot = await client.fetchAgent({ agentId: agent.id });
throw new Error(
`Expected idle after quick follow-up, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(getAssistantTexts(postSendMessages, agent.id))} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`,
);
@@ -114,7 +114,7 @@ describe("daemon E2E (real claude) - send while running recovery", () => {
}
await secondary.waitForFinish(agent.id, 180_000);
const finalResult = await secondary.fetchAgent(agent.id);
const finalResult = await secondary.fetchAgent({ agentId: agent.id });
if (finalResult) {
applySnapshot(finalResult.agent);
}
@@ -111,7 +111,7 @@ describe("daemon E2E (real codex) - send while running recovery", () => {
}
await secondary.waitForFinish(agent.id, 120_000);
const finalResult = await secondary.fetchAgent(agent.id);
const finalResult = await secondary.fetchAgent({ agentId: agent.id });
if (finalResult) {
applySnapshot(finalResult.agent);
}
@@ -513,7 +513,7 @@ test("creating another same-cwd local workspace keeps running status on the owni
throw new Error(third.error ?? "Expected third workspace to be created");
}
expect((await client.fetchAgent(agent.id))?.agent.status).toBe("running");
expect((await client.fetchAgent({ agentId: agent.id }))?.agent.status).toBe("running");
expect(await statusByWorkspaceId(client)).toEqual(
new Map([
[firstWorkspaceId, "running"],
@@ -565,7 +565,7 @@ test("two workspaces sharing one cwd compute agent status per workspaceId", asyn
);
await waitForPermission(client, agentA.id);
const fetchedA = await client.fetchAgent(agentA.id);
const fetchedA = await client.fetchAgent({ agentId: agentA.id });
expect(fetchedA?.agent.workspaceId).toBe(WORKSPACE_A);
expect(await statusByWorkspaceId(client)).toEqual(
@@ -609,7 +609,7 @@ test("two workspaces sharing one cwd compute agent status per workspaceId", asyn
);
await waitForPermission(client, agentB.id);
const fetchedB = await client.fetchAgent(agentB.id);
const fetchedB = await client.fetchAgent({ agentId: agentB.id });
expect(fetchedB?.agent.workspaceId).toBe(WORKSPACE_B);
expect(await statusByWorkspaceId(client)).toEqual(