diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml
index 64c19cfe08..b9492b33c5 100644
--- a/.github/workflows/build-desktop.yml
+++ b/.github/workflows/build-desktop.yml
@@ -159,6 +159,16 @@ jobs:
uses: dtolnay/rust-toolchain@1.93.0
with:
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
+ - name: Ensure macOS cross target on the toolchain-file toolchain
+ if: matrix.settings.platform == 'macos-latest'
+ run: |
+ # rust-toolchain.toml pins 1.96.1, which overrides the dtolnay-installed
+ # 1.93.0. The action's `targets:` input added the darwin targets to
+ # 1.93.0, not the override toolchain the vendored tauri-cli's CEF
+ # universal-helper build actually uses, so add them to the active
+ # (override) toolchain here to avoid E0463 "can't find crate for `core`".
+ cargo --version
+ rustup target add aarch64-apple-darwin x86_64-apple-darwin
- name: Install Tauri dependencies (ubuntu only)
if: startsWith(matrix.settings.platform, 'ubuntu-')
run: |
diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml
index fed1444cf0..2825207186 100644
--- a/.github/workflows/e2e-reusable.yml
+++ b/.github/workflows/e2e-reusable.yml
@@ -470,7 +470,15 @@ jobs:
run: |
rustup default 1.93.0 || true
which cargo
+ # `cargo --version` in the repo root materializes the
+ # rust-toolchain.toml override toolchain (1.96.1), which supersedes the
+ # dtolnay-installed 1.93.0. The action's `targets:` input added
+ # x86_64-apple-darwin to 1.93.0, NOT the override toolchain the CEF
+ # universal-helper build actually uses — so add it to the active
+ # (override) toolchain here or the cross-compile fails with E0463
+ # "can't find crate for `core`".
cargo --version
+ rustup target add x86_64-apple-darwin
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
@@ -669,7 +677,15 @@ jobs:
run: |
rustup default 1.93.0 || true
which cargo
+ # `cargo --version` in the repo root materializes the
+ # rust-toolchain.toml override toolchain (1.96.1), which supersedes the
+ # dtolnay-installed 1.93.0. The action's `targets:` input added
+ # x86_64-apple-darwin to 1.93.0, NOT the override toolchain the CEF
+ # universal-helper build actually uses — so add it to the active
+ # (override) toolchain here or the cross-compile fails with E0463
+ # "can't find crate for `core`".
cargo --version
+ rustup target add x86_64-apple-darwin
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
diff --git a/app/test/e2e/helpers/shared-flows.ts b/app/test/e2e/helpers/shared-flows.ts
index 9bec60cb5c..b24ca9355d 100644
--- a/app/test/e2e/helpers/shared-flows.ts
+++ b/app/test/e2e/helpers/shared-flows.ts
@@ -717,9 +717,13 @@ export async function walkOnboarding(logPrefix = '[E2E]', maxSteps = 12): Promis
await browser.pause(1_500);
}
- // Wait up to 15s for the onboarding shell to actually mount. If the user is
- // already onboarded (e.g. resuming an existing session) the button never
- // appears and we return without firing any clicks.
+ // Wait for the onboarding shell to actually mount. If the user is already
+ // onboarded (e.g. resuming an existing session — the common case in the
+ // shared-workspace E2E run) the button never appears and this wait is pure
+ // dead time on *every* resetApp, pushing the whole bring-up toward the 30s
+ // Mocha hook ceiling. 8s keeps ample headroom for a cold CEF boot to paint
+ // the button (it appears within a few seconds when onboarding is genuinely
+ // needed); the rare cold-boot miss is caught by the spec-file retry.
const appeared = await browser
.waitUntil(
async () =>
@@ -728,7 +732,7 @@ export async function walkOnboarding(logPrefix = '[E2E]', maxSteps = 12): Promis
() => document.querySelector('[data-testid="onboarding-next-button"]') !== null
)
),
- { timeout: 15_000, interval: 500, timeoutMsg: 'onboarding-next-button never appeared' }
+ { timeout: 8_000, interval: 500, timeoutMsg: 'onboarding-next-button never appeared' }
)
.catch(() => false);
diff --git a/app/test/e2e/specs/auth-access-control.spec.ts b/app/test/e2e/specs/auth-access-control.spec.ts
index 6b17816f9b..0f41b02229 100644
--- a/app/test/e2e/specs/auth-access-control.spec.ts
+++ b/app/test/e2e/specs/auth-access-control.spec.ts
@@ -107,7 +107,7 @@ async function performFullLogin(token = 'e2e-test-token') {
await waitForAppReady(15_000);
await waitForAuthBootstrap(15_000);
- const consumeCall = await waitForRequest('POST', '/telegram/login-tokens/', 20_000);
+ const consumeCall = await waitForRequest('POST', '/auth/login-token/consume', 20_000);
if (!consumeCall) {
console.log(
'[AuthAccess] Missing consume call. Request log:',
@@ -183,7 +183,7 @@ describe('Auth & Access Control', () => {
const homeText = await waitForHomePage(500);
if (homeText) return true;
const consumed = getRequestLog().find(
- r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/')
+ r => r.method === 'POST' && r.url.includes('/auth/login-token/consume')
);
return !!consumed;
},
@@ -211,7 +211,7 @@ describe('Auth & Access Control', () => {
await browser.waitUntil(
async () => {
const consumed = getRequestLog().find(
- r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/')
+ r => r.method === 'POST' && r.url.includes('/auth/login-token/consume')
);
return !!consumed;
},
@@ -230,7 +230,7 @@ describe('Auth & Access Control', () => {
expect(finalHome).not.toBeNull();
const consumeCall = getRequestLog().find(
- r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/')
+ r => r.method === 'POST' && r.url.includes('/auth/login-token/consume')
);
expect(consumeCall).toBeDefined();
console.log('[AuthAccess] Multi-device token accepted');
@@ -325,7 +325,7 @@ describe('Auth & Access Control', () => {
await browser.waitUntil(
async () => {
const consumed = getRequestLog().find(
- r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/')
+ r => r.method === 'POST' && r.url.includes('/auth/login-token/consume')
);
return !!consumed;
},
@@ -465,7 +465,7 @@ describe('Auth & Access Control', () => {
const homeText = await waitForHomePage(500);
if (!homeText) return true; // navigated away — auto-logout happened
const consumed = getRequestLog().find(
- r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/')
+ r => r.method === 'POST' && r.url.includes('/auth/login-token/consume')
);
return !!consumed;
},
diff --git a/app/test/e2e/specs/card-payment-flow.spec.ts b/app/test/e2e/specs/card-payment-flow.spec.ts
index 5c02f1af8b..84688eebfc 100644
--- a/app/test/e2e/specs/card-payment-flow.spec.ts
+++ b/app/test/e2e/specs/card-payment-flow.spec.ts
@@ -18,7 +18,10 @@ import { clearRequestLog, startMockServer, stopMockServer } from '../mock-server
const LOG_PREFIX = '[PaymentFlow]';
describe('Card Payment Flow', () => {
- before(async () => {
+ before(async function () {
+ // resetApp bring-up can run ~25-30s and race the default 30s Mocha hook
+ // budget; raise it.
+ this.timeout(90_000);
await startMockServer();
await waitForApp();
await resetApp('e2e-card-payment-token');
diff --git a/app/test/e2e/specs/chat-harness-scroll-render.spec.ts b/app/test/e2e/specs/chat-harness-scroll-render.spec.ts
index 49b551b089..05fdda823e 100644
--- a/app/test/e2e/specs/chat-harness-scroll-render.spec.ts
+++ b/app/test/e2e/specs/chat-harness-scroll-render.spec.ts
@@ -64,18 +64,19 @@ const STREAM_SCRIPT = [
{ finish: 'stop' },
];
-// The message column is the `
`
-// in Conversations.tsx. There's only one in /chat so a CSS predicate
-// matching the unique `bg-[#f6f6f6]` class is enough.
+// The message column is the `
` in Conversations.tsx.
+// There's only one in /chat so the structural class predicate is enough.
async function scrollMetrics(): Promise<{
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}> {
return (await browser.execute(() => {
- const el = document.querySelector(
- 'div.flex-1.overflow-y-auto.bg-\\[\\#f6f6f6\\]'
- ) as HTMLElement | null;
+ // The messages scroll container is `flex-1 min-h-0 overflow-y-auto`
+ // (Conversations.tsx). The former hardcoded `bg-[#f6f6f6]` class was
+ // replaced by a surface token, so match on the structural classes instead.
+ const el = document.querySelector('div.flex-1.min-h-0.overflow-y-auto') as HTMLElement | null;
if (!el) return { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
return {
scrollTop: el.scrollTop,
@@ -87,9 +88,10 @@ async function scrollMetrics(): Promise<{
async function scrollMessageColumn(top: number): Promise {
await browser.execute((y: number) => {
- const el = document.querySelector(
- 'div.flex-1.overflow-y-auto.bg-\\[\\#f6f6f6\\]'
- ) as HTMLElement | null;
+ // The messages scroll container is `flex-1 min-h-0 overflow-y-auto`
+ // (Conversations.tsx). The former hardcoded `bg-[#f6f6f6]` class was
+ // replaced by a surface token, so match on the structural classes instead.
+ const el = document.querySelector('div.flex-1.min-h-0.overflow-y-auto') as HTMLElement | null;
if (el) el.scrollTo({ top: y, behavior: 'auto' });
}, top);
}
diff --git a/app/test/e2e/specs/composio-triggers-flow.spec.ts b/app/test/e2e/specs/composio-triggers-flow.spec.ts
index 4fc64e18d5..9106513817 100644
--- a/app/test/e2e/specs/composio-triggers-flow.spec.ts
+++ b/app/test/e2e/specs/composio-triggers-flow.spec.ts
@@ -23,7 +23,9 @@ import { navigateToSkills } from '../helpers/shared-flows';
import { clearRequestLog, setMockBehavior, startMockServer, stopMockServer } from '../mock-server';
describe('Composio trigger toggles (UI + core RPC)', () => {
- before(async () => {
+ before(async function () {
+ // waitForApp() + resetApp() can exceed the default 30s Mocha hook budget.
+ this.timeout(90_000);
await startMockServer();
setMockBehavior(
'composioConnections',
diff --git a/app/test/e2e/specs/connector-jira.spec.ts b/app/test/e2e/specs/connector-jira.spec.ts
index 34461edf67..1277dbc59f 100644
--- a/app/test/e2e/specs/connector-jira.spec.ts
+++ b/app/test/e2e/specs/connector-jira.spec.ts
@@ -70,9 +70,21 @@ describe('Jira Composio connector flow', () => {
it('connect modal renders subdomain input field for Jira', async function () {
this.timeout(60_000);
- // Seed as idle (no active connection) so we see the connect flow
- seedComposioConnection(TOOLKIT_SLUG, 'CONNECTING', 'c-jira-idle');
+ // Seed as idle (no active connection) so we see the connect flow. The prior
+ // test rendered Jira ACTIVE, which persisted into the durable client-side
+ // connection cache (localStorage `composio:connections`). That cache
+ // re-seeds the tile as connected on remount — before the live `[]` fetch
+ // lands — and ComposioConnectModal latches its phase once at mount, so the
+ // modal would open stuck in the `connected` phase (no subdomain form).
+ // Clear the durable cache so the tile mounts disconnected and the modal
+ // opens in `idle`.
setMockBehavior('composioConnections', JSON.stringify([]));
+ // @ts-expect-error -- browser global is injected by WDIO at runtime, not typed in this env
+ await browser.execute(() => {
+ Object.keys(window.localStorage)
+ .filter(k => k.includes('composio:connections'))
+ .forEach(k => window.localStorage.removeItem(k));
+ });
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
const modal = await openConnectorModal(CONNECTOR_NAME);
diff --git a/app/test/e2e/specs/cron-jobs-flow.spec.ts b/app/test/e2e/specs/cron-jobs-flow.spec.ts
index 59630e298f..47c3920b87 100644
--- a/app/test/e2e/specs/cron-jobs-flow.spec.ts
+++ b/app/test/e2e/specs/cron-jobs-flow.spec.ts
@@ -105,7 +105,9 @@ async function openCronJobsPanel(): Promise {
}
describe('Cron jobs settings panel (real UI flow)', () => {
- before(async () => {
+ before(async function () {
+ // waitForApp() + resetApp() can exceed the default 30s Mocha hook budget.
+ this.timeout(90_000);
await startMockServer();
await waitForApp();
await resetApp(USER_ID);
diff --git a/app/test/e2e/specs/local-model-runtime.spec.ts b/app/test/e2e/specs/local-model-runtime.spec.ts
index 8b0663964f..1d81bc1fd6 100644
--- a/app/test/e2e/specs/local-model-runtime.spec.ts
+++ b/app/test/e2e/specs/local-model-runtime.spec.ts
@@ -67,7 +67,7 @@ describe.skip('Local model runtime flow', () => {
await waitForWebView(15_000);
await waitForAppReady(15_000);
- const consume = await waitForRequest('POST', '/telegram/login-tokens/');
+ const consume = await waitForRequest('POST', '/auth/login-token/consume');
expect(consume).toBeDefined();
await walkOnboarding('[LocalModel]');
diff --git a/app/test/e2e/specs/login-flow.spec.ts b/app/test/e2e/specs/login-flow.spec.ts
index 1843592e58..1f54d2ebb7 100644
--- a/app/test/e2e/specs/login-flow.spec.ts
+++ b/app/test/e2e/specs/login-flow.spec.ts
@@ -5,7 +5,7 @@
* Verifies the full auth + onboarding journey using mock data:
* Phase 1 — Deep link authentication:
* 1. `openhuman://auth?token=...` deep link is triggered via __simulateDeepLink
- * 2. App calls POST /telegram/login-tokens/:token/consume (mock server)
+ * 2. App calls POST /auth/login-token/consume (mock server)
* 3. App receives JWT, dispatches to Redux authSlice
* 4. UserProvider calls GET /auth/me (mock server)
*
@@ -143,7 +143,7 @@ describe('Login flow — complete with mock data (Linux)', () => {
});
it('mock server received the token-consume call', async () => {
- const call = await waitForRequest('POST', '/telegram/login-tokens/', 20_000);
+ const call = await waitForRequest('POST', '/auth/login-token/consume', 20_000);
if (!call) {
console.log(
'[LoginFlow] Missing consume call. Request log:',
@@ -304,7 +304,7 @@ describe('Login flow — complete with mock data (Linux)', () => {
await browser.pause(5_000);
// Verify the consume call was made (mock returns 401 for expired tokens)
- const call = await waitForRequest('POST', '/telegram/login-tokens/', 10_000);
+ const call = await waitForRequest('POST', '/auth/login-token/consume', 10_000);
expect(call).toBeDefined();
console.log('[LoginFlow] Expired token: consume call made (mock returns 401)');
@@ -321,7 +321,7 @@ describe('Login flow — complete with mock data (Linux)', () => {
await browser.pause(5_000);
// Verify the consume call was made (mock returns 401 for invalid tokens)
- const call = await waitForRequest('POST', '/telegram/login-tokens/', 10_000);
+ const call = await waitForRequest('POST', '/auth/login-token/consume', 10_000);
expect(call).toBeDefined();
console.log('[LoginFlow] Invalid token: consume call made (mock returns 401)');
@@ -350,7 +350,7 @@ describe('Login flow — complete with mock data (Linux)', () => {
// Assert NO consume call was made (bypass skips it)
const consumeCall = getRequestLog().find(
- r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/')
+ r => r.method === 'POST' && r.url.includes('/auth/login-token/consume')
);
expect(consumeCall).toBeUndefined();
console.log('[LoginFlow] Bypass auth: no consume call (correct — token set directly)');
diff --git a/app/test/e2e/specs/logout-relogin-onboarding.spec.ts b/app/test/e2e/specs/logout-relogin-onboarding.spec.ts
index fa6b626449..ffeb50c89f 100644
--- a/app/test/e2e/specs/logout-relogin-onboarding.spec.ts
+++ b/app/test/e2e/specs/logout-relogin-onboarding.spec.ts
@@ -107,7 +107,7 @@ describe('Logout -> re-login onboarding overlay', function () {
// ── Second login (re-login) ───────────────────────────────────────────────
// Use the bypass deep-link path (key=auth) which skips the
- // consumeLoginToken→/telegram/login-tokens/ exchange. After the complex
+ // consumeLoginToken→/auth/login-token/consume exchange. After the complex
// logout→test_reset→reload cycle, the full consume flow can race against
// waitForOAuthAuthReadiness timing — the bypass avoids that instability
// while still exercising the core auth path (storeSession, session-token
diff --git a/app/test/e2e/specs/mega-flow.spec.ts b/app/test/e2e/specs/mega-flow.spec.ts
index bf6a3c01a1..4f5bc3cbc7 100644
--- a/app/test/e2e/specs/mega-flow.spec.ts
+++ b/app/test/e2e/specs/mega-flow.spec.ts
@@ -105,7 +105,7 @@ async function resetEverything(label: string): Promise {
// scenario arrive and can be discarded — then clear AFTER the pause so
// stale requests don't appear in the next scenario's waitForMockRequest
// polls. (Clearing before the pause caused a race: in-flight /auth/me or
- // /telegram/login-tokens/ requests from scenario N arrived during the
+ // /auth/login-token/consume requests from scenario N arrived during the
// 800 ms window, landed in the fresh log, and were matched by scenario
// N+1's waitForMockRequest — causing composio RPCs to fire before the
// new deep-link's auth flow completed.)
@@ -145,7 +145,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
// -------------------------------------------------------------------------
// Scenario 1 — login via real token-consume deep link.
- // Expectation: the app POSTs to `/telegram/login-tokens/:t/consume`, gets a
+ // Expectation: the app POSTs to `/auth/login-token/consume`, gets a
// JWT back from the mock, and follows up with `GET /auth/me`.
// -------------------------------------------------------------------------
it('login: consume deep link triggers /consume + /auth/me on the mock', async () => {
@@ -154,7 +154,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
await triggerDeepLink('openhuman://auth?token=mega-login-token');
- const consume = await waitForMockRequest('POST', '/telegram/login-tokens/', 20_000);
+ const consume = await waitForMockRequest('POST', '/auth/login-token/consume', 20_000);
expect(consume).toBeDefined();
console.log(`${LOG} consume hit:`, consume?.url);
@@ -187,7 +187,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
expect(me).toBeDefined();
const consume = getRequestLog().find(
- r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/')
+ r => r.method === 'POST' && r.url.includes('/auth/login-token/consume')
);
expect(consume).toBeUndefined();
console.log(`${LOG} bypass: no consume call, /auth/me succeeded`);
@@ -211,7 +211,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
// Login first — `oauth:success` is only meaningful for an authenticated user.
await triggerDeepLink('openhuman://auth?token=mega-gmail-token');
- await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000);
+ await waitForMockRequest('POST', '/auth/login-token/consume', 15_000);
expect(await waitForMockRequest('GET', '/auth/me', 10_000)).toBeDefined();
clearRequestLog();
@@ -306,7 +306,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
await resetEverything('after Scenario 4');
await triggerDeepLink('openhuman://auth?token=mega-error-token');
- await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000);
+ await waitForMockRequest('POST', '/auth/login-token/consume', 15_000);
clearRequestLog();
await triggerDeepLink('openhuman://oauth/error?provider=google&error=access_denied');
@@ -337,7 +337,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
// Login so the RPC layer has an authenticated session.
await triggerDeepLink('openhuman://auth?token=mega-stale-thread-token');
- await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000);
+ await waitForMockRequest('POST', '/auth/login-token/consume', 15_000);
clearRequestLog();
// Attempt to append a message to a thread ID that does not exist.
@@ -373,7 +373,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
// Login so the RPC relay is authenticated.
await triggerDeepLink('openhuman://auth?token=mega-unknown-method-token');
- await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000);
+ await waitForMockRequest('POST', '/auth/login-token/consume', 15_000);
clearRequestLog();
// Call a method name that no controller has registered.
@@ -401,7 +401,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
await resetEverything('final');
await triggerDeepLink('openhuman://auth?token=mega-post-reset-token');
- const consume = await waitForMockRequest('POST', '/telegram/login-tokens/', 20_000);
+ const consume = await waitForMockRequest('POST', '/auth/login-token/consume', 20_000);
expect(consume).toBeDefined();
const me = await waitForMockRequest('GET', '/auth/me', 15_000);
expect(me).toBeDefined();
@@ -422,7 +422,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
await resetEverything('after Scenario 6');
await triggerDeepLink('openhuman://auth?token=mega-whatsapp-token');
- await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000);
+ await waitForMockRequest('POST', '/auth/login-token/consume', 15_000);
clearRequestLog();
// Seed two chats via the internal ingest path.
@@ -504,7 +504,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
// ── User A login ──────────────────────────────────────────────────────
await triggerDeepLink('openhuman://auth?token=mega-acct-switch-user-a');
- await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000);
+ await waitForMockRequest('POST', '/auth/login-token/consume', 15_000);
clearRequestLog();
// Create a thread as user A.
@@ -530,7 +530,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
await resetEverything('account switch to user B');
await triggerDeepLink('openhuman://auth?token=mega-acct-switch-user-b');
- await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000);
+ await waitForMockRequest('POST', '/auth/login-token/consume', 15_000);
clearRequestLog();
// Verify RPC is healthy for "User B". The thread list is a valid array
@@ -551,7 +551,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
await resetEverything('account switch back to user A');
await triggerDeepLink('openhuman://auth?token=mega-acct-switch-user-a');
- await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000);
+ await waitForMockRequest('POST', '/auth/login-token/consume', 15_000);
clearRequestLog();
const listA2 = await callOpenhumanRpc('openhuman.threads_list', {});
@@ -680,7 +680,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
// Login so the RPC relay is authenticated.
await triggerDeepLink('openhuman://auth?token=mega-update-version-token');
- await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000);
+ await waitForMockRequest('POST', '/auth/login-token/consume', 15_000);
clearRequestLog();
const result = await callOpenhumanRpc('openhuman.update_version', {});
@@ -741,7 +741,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
await resetEverything('before notification-dedup scenario');
await triggerDeepLink('openhuman://auth?token=mega-notification-dedup-token');
- await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000);
+ await waitForMockRequest('POST', '/auth/login-token/consume', 15_000);
clearRequestLog();
const notifPayload = {
@@ -805,7 +805,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
await resetEverything('before thread-crud-smoke scenario');
await triggerDeepLink('openhuman://auth?token=mega-thread-crud-token');
- await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000);
+ await waitForMockRequest('POST', '/auth/login-token/consume', 15_000);
clearRequestLog();
// Step 1 — create a fresh thread.
diff --git a/app/test/e2e/specs/navigation-settings-panels.spec.ts b/app/test/e2e/specs/navigation-settings-panels.spec.ts
index 847405c4f7..1e0a354d49 100644
--- a/app/test/e2e/specs/navigation-settings-panels.spec.ts
+++ b/app/test/e2e/specs/navigation-settings-panels.spec.ts
@@ -192,7 +192,9 @@ describe('Navigation — settings sub-panels', () => {
expect(homeText).toBeTruthy();
const hash = await browser.execute(() => window.location.hash);
- expect(hash).toMatch(/^#\/home/);
+ // Home merged into the unified chat surface: navigateToHome() lands on
+ // #/chat (/home redirects there). Accept either.
+ expect(hash).toMatch(/^#\/(chat|home)/);
console.log(`${LOG_PREFIX} N2.9: passed — home content: "${homeText}"`);
});
});
diff --git a/app/test/e2e/specs/navigation.spec.ts b/app/test/e2e/specs/navigation.spec.ts
index 5448f6c8b3..458dc13aac 100644
--- a/app/test/e2e/specs/navigation.spec.ts
+++ b/app/test/e2e/specs/navigation.spec.ts
@@ -32,15 +32,15 @@ interface Route {
// /home → /chat (Phase 6 — /home is now the merged chat surface)
// /human → /chat (Phase 6 — back-compat redirect)
// /skills → /connections (Phase 2 — back-compat redirect)
-// /intelligence → /activity (Phase 3 — back-compat redirect)
-// Note: /home is intentionally omitted here because AppRoutes.tsx redirects it
-// to /chat — navigateViaHash('/home') settles on #/chat, which is covered by
-// the /chat row. Keeping /home in ROUTES would cause the hash assertion to
-// fail since the actual hash is #/chat, not #/home.
+// /intelligence → /settings/notifications (Phase 3 — back-compat redirect)
+// /activity → /settings/notifications (back-compat redirect)
+// Note: /home and /activity are intentionally omitted here because AppRoutes.tsx
+// now redirects them (/home → /chat, /activity → /settings/notifications).
+// navigateViaHash settles on the redirect target, so keeping them in ROUTES
+// would fail the `^#` assertion (actual hash is the redirect destination).
const ROUTES: Route[] = [
{ hash: '/chat' },
{ hash: '/connections' },
- { hash: '/activity' },
{ hash: '/rewards' },
{ hash: '/settings' },
{ hash: '/agent-world' },
diff --git a/app/test/e2e/specs/notifications.spec.ts b/app/test/e2e/specs/notifications.spec.ts
index 6e496cf77c..bb7bbef658 100644
--- a/app/test/e2e/specs/notifications.spec.ts
+++ b/app/test/e2e/specs/notifications.spec.ts
@@ -70,7 +70,10 @@ async function waitForCoreSidecar(timeout = 30_000): Promise {
let ingestedNotifId: string | undefined;
describe('Notifications', () => {
- before(async () => {
+ before(async function () {
+ // waitForApp() + waitForCoreSidecar(30s) cannot fit the default 30s Mocha
+ // hook budget; raise it like the other real-flow specs do.
+ this.timeout(90_000);
await startMockServer();
await waitForApp();
await resetApp('e2e-notifications-user');
diff --git a/app/test/e2e/specs/onboarding-modes.spec.ts b/app/test/e2e/specs/onboarding-modes.spec.ts
index f9e7774ac7..7093537af0 100644
--- a/app/test/e2e/specs/onboarding-modes.spec.ts
+++ b/app/test/e2e/specs/onboarding-modes.spec.ts
@@ -147,7 +147,10 @@ async function waitForHome(timeout = 20_000): Promise {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const hash = await currentHash();
- if (hash.startsWith('#/home')) return true;
+ // Home was merged into the unified chat surface: /home redirects to /chat
+ // (AppRoutes.tsx). Accept either so the wizard's post-finish landing check
+ // survives the IA change.
+ if (hash.startsWith('#/home') || hash.startsWith('#/chat')) return true;
await pause(400);
}
return false;
@@ -254,7 +257,13 @@ describe('Onboarding modes — Simple (Cloud) vs Advanced (Custom)', () => {
await clickTestId('onboarding-runtime-choice-custom');
await pause(300);
}
- await clickOnboardingNext();
+ // Only advance past RuntimeChoice if we're still on that step. In local
+ // session mode RuntimeChoicePage auto-redirects to custom/inference, so
+ // an unconditional Next here over-advances inference→voice (matches the
+ // Phase C guard below).
+ if (await testIdExists('onboarding-runtime-choice-step', 500)) {
+ await clickOnboardingNext();
+ }
}
}
// else: local session mode — already redirected to custom/inference, continue there.
@@ -395,6 +404,13 @@ describe('Onboarding modes — Simple (Cloud) vs Advanced (Custom)', () => {
}, want);
expect(dispatched).toBe(true);
+ // Voice Routing was decoupled into staged edit + explicit Save: the select's
+ // onChange only stages `sttProvider` (VoicePanel `onSttProviderChange`);
+ // persistence to config.toml happens on the always-rendered Save button
+ // (`save-voice-routing`, enabled once there are routing changes). Click it so
+ // the staged provider actually writes through.
+ expect(await clickTestId('save-voice-routing')).toBe(true);
+
// Poll config.toml for the new value.
let onDisk: string | null = null;
const deadline = Date.now() + 10_000;
diff --git a/app/test/e2e/specs/rewards-progression-persistence.spec.ts b/app/test/e2e/specs/rewards-progression-persistence.spec.ts
index a69e2ecf10..52a536a036 100644
--- a/app/test/e2e/specs/rewards-progression-persistence.spec.ts
+++ b/app/test/e2e/specs/rewards-progression-persistence.spec.ts
@@ -98,6 +98,9 @@ async function getRewardsMetricValue(label: string): Promise {
describe('Rewards progression & persistence', () => {
before(async function beforeSuite() {
+ // resetApp bring-up can run ~25-30s and race the default 30s Mocha hook
+ // budget; raise it.
+ this.timeout(90_000);
if (!supportsExecuteScript()) {
stepLog('Skipping suite on Mac2 — Rewards bottom-tab label not mapped for Appium');
this.skip();
diff --git a/app/test/e2e/specs/settings-advanced-config.spec.ts b/app/test/e2e/specs/settings-advanced-config.spec.ts
index 4e8ac1d9c8..5a23384980 100644
--- a/app/test/e2e/specs/settings-advanced-config.spec.ts
+++ b/app/test/e2e/specs/settings-advanced-config.spec.ts
@@ -121,7 +121,23 @@ describe('Settings - Advanced Config', () => {
const input = await browser.$('#autonomy-max-actions');
await input.waitForExist({ timeout: 10_000 });
- await input.setValue(String(target));
+ // Drive the controlled number input via the native value setter + React
+ // change event. WebDriver's setValue clears the field but doesn't reliably
+ // fire React's synthetic onChange, so `draft`/`isChanged` wouldn't update
+ // and the Save button (canSave = isValid && isChanged) would stay a
+ // disabled no-op — "Saved." would never appear.
+ await browser.execute((val: string) => {
+ const el = document.querySelector('#autonomy-max-actions');
+ if (!el) return;
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ 'value'
+ )?.set;
+ if (setter) setter.call(el, val);
+ else el.value = val;
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ }, String(target));
await clickText('Save', 10_000);
await waitForText('Saved.', 10_000);
diff --git a/app/test/e2e/specs/settings-channels-permissions.spec.ts b/app/test/e2e/specs/settings-channels-permissions.spec.ts
index faba179be8..99d365af5b 100644
--- a/app/test/e2e/specs/settings-channels-permissions.spec.ts
+++ b/app/test/e2e/specs/settings-channels-permissions.spec.ts
@@ -34,7 +34,10 @@ async function defaultMessagingChannel(): Promise {
const USER_ID = 'e2e-settings-channels';
describe('Settings - Channels & Permissions', () => {
- before(async () => {
+ before(async function () {
+ // resetApp bring-up can run ~25-30s and race the default 30s Mocha hook
+ // budget; raise it.
+ this.timeout(90_000);
await startMockServer();
await waitForApp();
await resetApp(USER_ID);
diff --git a/app/test/e2e/specs/settings-dev-options.spec.ts b/app/test/e2e/specs/settings-dev-options.spec.ts
index d56736d7e1..91c2b523e7 100644
--- a/app/test/e2e/specs/settings-dev-options.spec.ts
+++ b/app/test/e2e/specs/settings-dev-options.spec.ts
@@ -35,7 +35,10 @@ describe('Settings - Developer Options', () => {
this.timeout(90_000);
await navigateViaHash('/settings/webhooks-debug');
- await waitForText('Webhooks Debug', 15_000);
+ // Panel heading comes from the route registry titleKey
+ // (settings.developerMenu.webhooks.title = "Webhooks"); the old
+ // "Webhooks Debug" (webhooks.debugTitle) is no longer rendered.
+ await waitForText('Webhooks', 15_000);
await waitForText('Registered Webhooks', 15_000);
await waitForText('Captured Requests', 15_000);
expect(await textExists('Refresh')).toBe(true);
@@ -43,9 +46,11 @@ describe('Settings - Developer Options', () => {
it('mounts Memory Debug panel (13.4.3)', async function () {
this.timeout(90_000);
+ // /settings/memory-debug now redirects to /brain?tab=memory-debug, where
+ // the relocated MemoryDebugPanel renders (no "Memory Debug" heading of its
+ // own). Assert on the section content that still renders there.
await navigateViaHash('/settings/memory-debug');
- await waitForText('Memory Debug', 15_000);
await waitForText('Documents', 15_000);
await waitForText('Namespaces', 15_000);
await waitForText('Query & Recall', 15_000);
@@ -56,7 +61,9 @@ describe('Settings - Developer Options', () => {
this.timeout(90_000);
await navigateViaHash('/settings/autocomplete-debug');
- await waitForText('Autocomplete Debug', 15_000);
+ // Panel heading is settings.developerMenu.autocomplete.title = "Autocomplete";
+ // the old "Autocomplete Debug" (autocomplete.debugTitle) is no longer used.
+ await waitForText('Autocomplete', 15_000);
await waitForText('Live Logs', 15_000);
const logsFound = (await textExists('No logs yet.')) || (await textExists('[runtime]'));
diff --git a/app/test/e2e/specs/skill-execution-flow.spec.ts b/app/test/e2e/specs/skill-execution-flow.spec.ts
index db7273ab42..90b984c322 100644
--- a/app/test/e2e/specs/skill-execution-flow.spec.ts
+++ b/app/test/e2e/specs/skill-execution-flow.spec.ts
@@ -31,7 +31,8 @@ describe('Skill discovery (UI + core RPC)', () => {
it('lands the user on a logged-in shell', async () => {
const atHome =
(await textExists('Ask your assistant anything')) ||
- (await textExists('Your device is connected'));
+ (await textExists('Your device is connected')) ||
+ (await textExists('Your assistant is ready when you are'));
expect(atHome).toBe(true);
});
diff --git a/app/test/e2e/specs/tool-browser-flow.spec.ts b/app/test/e2e/specs/tool-browser-flow.spec.ts
index de3594f309..c8535d27ba 100644
--- a/app/test/e2e/specs/tool-browser-flow.spec.ts
+++ b/app/test/e2e/specs/tool-browser-flow.spec.ts
@@ -64,7 +64,10 @@ interface ListDefinitionsResult {
}
describe('System tools — Browser (open URL + automation registry)', () => {
- before(async () => {
+ before(async function () {
+ // resetApp bring-up can run ~25-30s and race the default 30s Mocha hook
+ // budget; raise it.
+ this.timeout(90_000);
await startMockServer();
await waitForApp();
await resetApp(USER_ID);
diff --git a/app/test/e2e/specs/voice-mode.spec.ts b/app/test/e2e/specs/voice-mode.spec.ts
index 9da14686bf..b3b5a3177f 100644
--- a/app/test/e2e/specs/voice-mode.spec.ts
+++ b/app/test/e2e/specs/voice-mode.spec.ts
@@ -84,7 +84,7 @@ describe.skip('Voice mode integration', () => {
await waitForWebView(15_000);
await waitForAppReady(15_000);
- const consume = await waitForRequest('POST', '/telegram/login-tokens/');
+ const consume = await waitForRequest('POST', '/auth/login-token/consume');
expect(consume).toBeDefined();
await completeOnboardingIfVisible('[VoiceModeE2E]');
diff --git a/app/test/e2e/specs/webhooks-ingress-flow.spec.ts b/app/test/e2e/specs/webhooks-ingress-flow.spec.ts
index 54409b6c5e..efcf8880a7 100644
--- a/app/test/e2e/specs/webhooks-ingress-flow.spec.ts
+++ b/app/test/e2e/specs/webhooks-ingress-flow.spec.ts
@@ -24,7 +24,10 @@ async function openWebhooksDebugPanel(): Promise {
}
describe('Webhooks ingress surface (stub-level)', () => {
- before(async () => {
+ before(async function () {
+ // resetApp bring-up (waitForApp + onboarding walk + home confirm) can run
+ // ~25-30s and race the default 30s Mocha hook budget; raise it.
+ this.timeout(90_000);
await startMockServer();
await waitForApp();
await resetApp(USER_ID);
@@ -104,7 +107,9 @@ describe('Webhooks ingress surface (stub-level)', () => {
stepLog('Navigated to webhooks debug route', { currentHash });
expect(String(currentHash)).toContain('/settings/webhooks-debug');
- await waitForText('Webhooks Debug', 12_000);
+ // Panel heading is the route-registry title "Webhooks" now (the old
+ // "Webhooks Debug" webhooks.debugTitle is no longer rendered).
+ await waitForText('Webhooks', 12_000);
await waitForText('Registered Webhooks', 12_000);
await waitForText('Captured Requests', 12_000);
diff --git a/app/test/e2e/specs/webhooks-tunnel-flow.spec.ts b/app/test/e2e/specs/webhooks-tunnel-flow.spec.ts
index 9a7871cb59..b002f113e5 100644
--- a/app/test/e2e/specs/webhooks-tunnel-flow.spec.ts
+++ b/app/test/e2e/specs/webhooks-tunnel-flow.spec.ts
@@ -64,7 +64,10 @@ function unwrapRpcValue(raw: unknown): T | undefined {
}
describe('Webhook tunnel CRUD (UI + core RPC + mock backend)', () => {
- before(async () => {
+ before(async function () {
+ // resetApp bring-up can run ~25-30s and race the default 30s Mocha hook
+ // budget; raise it.
+ this.timeout(90_000);
await startMockServer();
await resetMockBehavior();
await waitForApp();
diff --git a/app/test/playwright/specs/login-flow.spec.ts b/app/test/playwright/specs/login-flow.spec.ts
index 7346c4229e..6c78883cb4 100644
--- a/app/test/playwright/specs/login-flow.spec.ts
+++ b/app/test/playwright/specs/login-flow.spec.ts
@@ -64,7 +64,7 @@ test.describe('Login Flow', () => {
.toMatch(/^#\/(home|chat)(\/|$)/);
const consumeCall = (await requests()).find(
- request => request.method === 'POST' && request.url.includes('/telegram/login-tokens/')
+ request => request.method === 'POST' && request.url.includes('/auth/login-token/consume')
);
expect(consumeCall).toBeUndefined();
await expect(await waitForMockRequest('GET', '/auth/me')).toBeTruthy();
diff --git a/app/test/wdio.conf.ts b/app/test/wdio.conf.ts
index a6f0dfbe61..c297a5a158 100644
--- a/app/test/wdio.conf.ts
+++ b/app/test/wdio.conf.ts
@@ -115,6 +115,12 @@ export const config: Options.Testrunner & Record = {
// run. `--bail` on e2e-run-all-flows.sh sets E2E_BAIL_ON_FAILURE=1 so we
// flip this to 1 (= stop after the first failed spec).
bail: process.env.E2E_BAIL_ON_FAILURE === '1' ? 1 : 0,
+ // Retry a failed spec file once (unless bailing) to absorb transient
+ // shared-session flakiness — cold-start warmups and `resetApp` bring-up
+ // races that sit right at the Mocha hook budget. Deterministic failures
+ // still fail on the retry; this only rescues the genuinely-flaky ones.
+ specFileRetries: process.env.E2E_BAIL_ON_FAILURE === '1' ? 0 : 1,
+ specFileRetriesDeferred: true,
waitforTimeout: 10_000,
connectionRetryTimeout: 120_000,
connectionRetryCount: 3,
diff --git a/scripts/mock-api/routes/auth.mjs b/scripts/mock-api/routes/auth.mjs
index 93f1170afe..a81350345d 100644
--- a/scripts/mock-api/routes/auth.mjs
+++ b/scripts/mock-api/routes/auth.mjs
@@ -11,9 +11,15 @@ export async function handleAuth(ctx) {
const { method, url, res, origin } = ctx;
const mockBehavior = behavior();
+ // Login-token consume. The core POSTs to `/auth/login-token/consume` with the
+ // token in a JSON body `{ token, audience? }` and parses `{ success, data: { jwt } }`
+ // (see src/api/rest.rs `consume_login_token`). The legacy path-param route
+ // `/telegram/login-tokens/:token/consume` was removed backend-side but is kept
+ // here as a harmless alias in case an older client is exercised.
if (
method === "POST" &&
- /^\/telegram\/login-tokens\/[^/]+\/consume\/?$/.test(url)
+ (/^\/auth\/login-token\/consume\/?$/.test(url) ||
+ /^\/telegram\/login-tokens\/[^/]+\/consume\/?$/.test(url))
) {
if (mockBehavior.token === "expired") {
json(res, 401, { success: false, error: "Token expired or invalid" });
@@ -24,7 +30,7 @@ export async function handleAuth(ctx) {
return true;
}
const jwt = mockBehavior.jwt ? `${MOCK_JWT}-${mockBehavior.jwt}` : MOCK_JWT;
- json(res, 200, { success: true, data: { jwtToken: jwt } });
+ json(res, 200, { success: true, data: { jwt } });
return true;
}
diff --git a/src/openhuman/tinyagents/observability.rs b/src/openhuman/tinyagents/observability.rs
index 912a64e903..8ea9c9168c 100644
--- a/src/openhuman/tinyagents/observability.rs
+++ b/src/openhuman/tinyagents/observability.rs
@@ -1094,6 +1094,7 @@ mod tests {
sink.emit(AgentEvent::ToolCompleted {
call_id: "c1".into(),
tool_name: "echo".to_string(),
+ started_at_ms: None,
input: None,
output: None,
});