diff --git a/.mise.toml b/.mise.toml
new file mode 100644
index 0000000000..364d9793d2
--- /dev/null
+++ b/.mise.toml
@@ -0,0 +1,3 @@
+[tools]
+node = "24.13.1"
+bun = "1.3.9"
diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts
index b6ae7ee982..8b04c89e22 100644
--- a/apps/server/integration/OrchestrationEngineHarness.integration.ts
+++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts
@@ -114,7 +114,7 @@ function waitFor(
read: Effect.Effect,
predicate: (value: A) => boolean,
description: string,
- timeoutMs = 3000,
+ timeoutMs = 10_000,
): Effect.Effect {
const RETRY_SIGNAL = "wait_for_retry";
const retryIntervalMs = 10;
diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
index d675c85ff5..bce244780e 100644
--- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
@@ -112,7 +112,7 @@ async function waitForThread(
checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;
activities: ReadonlyArray<{ kind: string }>;
}) => boolean,
- timeoutMs = 2000,
+ timeoutMs = 5000,
) {
const deadline = Date.now() + timeoutMs;
const poll = async (): Promise<{
@@ -137,7 +137,7 @@ async function waitForThread(
async function waitForEvent(
engine: OrchestrationEngineShape,
predicate: (event: { type: string }) => boolean,
- timeoutMs = 2000,
+ timeoutMs = 5000,
) {
const deadline = Date.now() + timeoutMs;
const poll = async () => {
@@ -188,7 +188,7 @@ function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string {
return runGit(cwd, ["show", `${ref}:${filePath}`]);
}
-async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 2000) {
+async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
const poll = async (): Promise => {
if (gitRefExists(cwd, ref)) {
diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts
index 285028cca6..54469a2006 100644
--- a/apps/server/src/wsServer.test.ts
+++ b/apps/server/src/wsServer.test.ts
@@ -214,7 +214,7 @@ class MockTerminalManager implements TerminalManagerShape {
readonly dispose: TerminalManagerShape["dispose"] = Effect.void;
}
-function connectWs(port: number, token?: string): Promise {
+function connectWsOnce(port: number, token?: string): Promise {
return new Promise((resolve, reject) => {
const query = token ? `?token=${encodeURIComponent(token)}` : "";
const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`);
@@ -236,6 +236,25 @@ function connectWs(port: number, token?: string): Promise {
});
}
+async function connectWs(port: number, token?: string, attempts = 5): Promise {
+ let lastError: unknown = new Error("WebSocket connection failed");
+
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
+ try {
+ // The HTTP server can report a bound port just before websocket upgrades
+ // are consistently accepted, so allow a few short retries in tests.
+ return await connectWsOnce(port, token);
+ } catch (error) {
+ lastError = error;
+ if (attempt < attempts - 1) {
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ }
+ }
+ }
+
+ throw lastError;
+}
+
function waitForMessage(ws: WebSocket): Promise {
const pending = pendingBySocket.get(ws);
if (!pending) {
@@ -252,6 +271,36 @@ function waitForMessage(ws: WebSocket): Promise {
});
}
+// waitForPush can filter many unrelated envelopes before finding a match. When
+// no more frames arrive, an idle timeout gives the test a precise failure
+// instead of hanging until the suite-level test timeout expires.
+function waitForMessageWithTimeout(ws: WebSocket, timeoutMs: number): Promise {
+ const pending = pendingBySocket.get(ws);
+ if (!pending) {
+ return Promise.reject(new Error("WebSocket not initialized"));
+ }
+
+ const queued = pending.queue.shift();
+ if (queued !== undefined) {
+ return Promise.resolve(queued);
+ }
+
+ return new Promise((resolve, reject) => {
+ const waiter = (message: unknown) => {
+ clearTimeout(timeoutId);
+ resolve(message);
+ };
+ const timeoutId = setTimeout(() => {
+ const index = pending.waiters.indexOf(waiter);
+ if (index >= 0) {
+ pending.waiters.splice(index, 1);
+ }
+ reject(new Error(`Timed out waiting for WebSocket message after ${timeoutMs}ms`));
+ }, timeoutMs);
+ pending.waiters.push(waiter);
+ });
+}
+
function asWebSocketResponse(message: unknown): WebSocketResponse | null {
if (typeof message !== "object" || message === null) return null;
if (!("id" in message)) return null;
@@ -295,12 +344,13 @@ async function waitForPush(
channel: string,
predicate?: (push: WsPush) => boolean,
maxMessages = 120,
+ idleTimeoutMs = 5_000,
): Promise {
const take = async (remaining: number): Promise => {
if (remaining <= 0) {
throw new Error(`Timed out waiting for push on ${channel}`);
}
- const message = (await waitForMessage(ws)) as WsPush;
+ const message = (await waitForMessageWithTimeout(ws, idleTimeoutMs)) as WsPush;
if (message.type !== "push" || message.channel !== channel) {
return take(remaining - 1);
}
@@ -312,6 +362,30 @@ async function waitForPush(
return take(maxMessages);
}
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+async function rewriteKeybindingsAndWaitForPush(
+ ws: WebSocket,
+ keybindingsPath: string,
+ contents: string,
+ predicate: (push: WsPush) => boolean,
+ attempts = 6,
+): Promise {
+ let lastError: unknown = new Error("Timed out waiting for keybindings watcher push");
+
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
+ fs.writeFileSync(keybindingsPath, contents, "utf8");
+ try {
+ return await waitForPush(ws, WS_CHANNELS.serverConfigUpdated, predicate, 12, 250);
+ } catch (error) {
+ lastError = error;
+ await sleep(25);
+ }
+ }
+
+ throw lastError;
+}
+
async function requestPath(
port: number,
requestPath: string,
@@ -889,10 +963,10 @@ describe("WebSocket Server", () => {
connections.push(ws);
await waitForMessage(ws);
- fs.writeFileSync(keybindingsPath, "{ not-json", "utf8");
- const malformedPush = await waitForPush(
+ const malformedPush = await rewriteKeybindingsAndWaitForPush(
ws,
- WS_CHANNELS.serverConfigUpdated,
+ keybindingsPath,
+ "{ not-json",
(push) =>
Array.isArray((push.data as { issues?: unknown[] }).issues) &&
Boolean((push.data as { issues: Array<{ kind: string }> }).issues[0]) &&
@@ -904,10 +978,10 @@ describe("WebSocket Server", () => {
providers: defaultProviderStatuses,
});
- fs.writeFileSync(keybindingsPath, "[]", "utf8");
- const successPush = await waitForPush(
+ const successPush = await rewriteKeybindingsAndWaitForPush(
ws,
- WS_CHANNELS.serverConfigUpdated,
+ keybindingsPath,
+ "[]",
(push) =>
Array.isArray((push.data as { issues?: unknown[] }).issues) &&
(push.data as { issues: unknown[] }).issues.length === 0,
@@ -1333,7 +1407,16 @@ describe("WebSocket Server", () => {
};
terminalManager.emitEvent(manualEvent);
- const push = (await waitForMessage(ws)) as WsPush;
+ // Startup keybindings broadcasts can race ahead of terminal pushes, so match
+ // the manual output payload instead of assuming the next push is terminal-related.
+ const push = await waitForPush(
+ ws,
+ WS_CHANNELS.terminalEvent,
+ (candidate) => {
+ const event = candidate.data as TerminalEvent;
+ return event.type === "output" && event.data === manualEvent.data;
+ },
+ );
expect(push.type).toBe("push");
expect(push.channel).toBe(WS_CHANNELS.terminalEvent);
expect((push.data as TerminalEvent).type).toBe("output");
diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts
new file mode 100644
index 0000000000..cb86636fb6
--- /dev/null
+++ b/apps/server/vitest.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig, mergeConfig } from "vitest/config";
+
+import baseConfig from "../../vitest.config";
+
+export default mergeConfig(
+ baseConfig,
+ defineConfig({
+ test: {
+ testTimeout: 15_000,
+ hookTimeout: 15_000,
+ },
+ }),
+);
diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts
index e47073fc21..bfa2fa38df 100644
--- a/apps/web/src/wsTransport.test.ts
+++ b/apps/web/src/wsTransport.test.ts
@@ -158,12 +158,17 @@ describe("WsTransport", () => {
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({ ok: true });
expect(warnSpy).toHaveBeenCalledTimes(2);
- expect(warnSpy).toHaveBeenNthCalledWith(1, "Dropped inbound WebSocket envelope", {
- reason: "decode-failed",
- issue:
- "SchemaError: SyntaxError: Expected property name or '}' in JSON at position 2 (line 1 column 3)",
- raw: "{ invalid-json",
- });
+ expect(warnSpy).toHaveBeenNthCalledWith(
+ 1,
+ "Dropped inbound WebSocket envelope",
+ expect.objectContaining({
+ reason: "decode-failed",
+ issue: expect.stringContaining(
+ "SchemaError: SyntaxError: Expected property name or '}' in JSON at position 2",
+ ),
+ raw: "{ invalid-json",
+ }),
+ );
expect(warnSpy).toHaveBeenNthCalledWith(2, "Dropped inbound WebSocket envelope", {
reason: "decode-failed",
issue: expect.stringContaining("SchemaError: Expected string, got 42"),