test: harden daemon lifecycle and e2e reliability across app/cli/server

This commit is contained in:
Mohamed Boudra
2026-02-25 10:21:46 +07:00
parent 094ce49d4f
commit a060ae1256
98 changed files with 5716 additions and 1771 deletions
@@ -14,8 +14,6 @@ test("agent details sheet shows IDs and copy toast", async ({ page }) => {
await createAgent(page, prompt);
await page.getByTestId("agent-overflow-menu").click();
await page.getByTestId("agent-menu-details").click();
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
@@ -4,20 +4,16 @@ import { createTempGitRepo } from './helpers/workspace';
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
const repo = await createTempGitRepo();
const marker = 'TIMELINE_HYDRATION_OK';
const prompt = `Respond with exactly: ${marker}`;
const prompt = 'Respond with exactly: TIMELINE_HYDRATION_OK';
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, prompt);
const assistantMessage = page
.getByTestId('assistant-message')
.filter({ hasText: marker })
.first();
await expect(assistantMessage).toBeVisible({ timeout: 120000 });
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
await page.reload({ waitUntil: 'commit' });
await expect(page).toHaveURL(/\/agent\//);
@@ -25,9 +21,6 @@ test('agent timeline hydrates after reload via fetch_agent_timeline_request', as
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
await expect(
page.getByTestId('assistant-message').filter({ hasText: marker }).first()
).toBeVisible({ timeout: 30000 });
} finally {
await repo.cleanup();
}
+163 -151
View File
@@ -4,6 +4,7 @@ import { execSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { test, expect, type Page } from './fixtures';
import {
createAgent,
ensureHostSelected,
gotoHome,
setWorkingDirectory,
@@ -12,6 +13,10 @@ import { createTempGitRepo } from './helpers/workspace';
test.describe.configure({ mode: 'serial', timeout: 120000 });
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getChangesScope(page: Page) {
return page.locator('[data-testid="explorer-content-area"]:visible').first();
}
@@ -20,6 +25,26 @@ function getChangesHeader(page: Page) {
return getChangesScope(page).getByTestId('changes-header');
}
async function ensureExplorerTabsVisible(page: Page) {
const changesTab = page.getByTestId('explorer-tab-changes').first();
if (await changesTab.isVisible().catch(() => false)) {
return;
}
const toggle = page
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
.first();
await expect(toggle).toBeVisible({ timeout: 10000 });
for (let attempt = 0; attempt < 4; attempt += 1) {
if (await changesTab.isVisible().catch(() => false)) {
return;
}
await toggle.click();
await page.waitForTimeout(200);
}
await expect(changesTab).toBeVisible({ timeout: 30000 });
}
async function selectChangesView(page: Page, view: 'working' | 'base') {
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
@@ -32,6 +57,11 @@ async function selectChangesView(page: Page, view: 'working' | 'base') {
await overflowBackdrop.click({ force: true });
await expect(overflowBackdrop).toHaveCount(0);
}
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
if (await diffModeBackdrop.isVisible().catch(() => false)) {
await diffModeBackdrop.click({ force: true });
await expect(diffModeBackdrop).toHaveCount(0);
}
const scope = getChangesScope(page);
const modeToggle = scope.getByTestId('changes-diff-status').first();
@@ -42,16 +72,15 @@ async function selectChangesView(page: Page, view: 'working' | 'base') {
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
if (current !== expected) {
await modeToggle.click();
const menu = page.getByTestId('changes-diff-status-menu');
await expect(menu).toBeVisible({ timeout: 10000 });
const optionTestId =
view === 'working' ? 'changes-diff-mode-uncommitted' : 'changes-diff-mode-committed';
await page.getByTestId(optionTestId).click({ force: true });
}
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
}
async function openChangesOverflowMenu(page: Page) {
const menuButton = getChangesScope(page).locator('[data-testid="changes-overflow-menu"]:visible').first();
await expect(menuButton).toBeVisible();
await menuButton.click();
}
async function openChangesPrimaryMenu(page: Page) {
const scope = getChangesScope(page);
const caret = scope.getByTestId('changes-primary-cta-caret').first();
@@ -62,25 +91,10 @@ async function openChangesPrimaryMenu(page: Page) {
}
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
await ensureExplorerTabsVisible(page);
const changesHeader = getChangesHeader(page);
if (!(await changesHeader.isVisible())) {
const explorerHeader = page.getByTestId('explorer-header');
if (await explorerHeader.isVisible()) {
const changesTab = explorerHeader.getByText('Changes', { exact: true });
if (await changesTab.isVisible().catch(() => false)) {
await changesTab.click();
} else {
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
await overflowMenu.click();
await page.getByText(/view changes/i).first().click();
}
} else {
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
await overflowMenu.click();
await page.getByText(/view changes/i).first().click();
}
await page.getByTestId('explorer-tab-changes').first().click();
}
await expect(changesHeader).toBeVisible({ timeout: 30000 });
if (options?.expectGit === false) {
@@ -95,98 +109,62 @@ async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
});
}
async function sendPrompt(page: Page, prompt: string) {
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await input.fill(prompt);
await input.press('Enter');
async function waitForAgentTurnToSettle(page: Page, timeout = 90000) {
const stopButton = page.getByRole('button', { name: /stop agent|stop/i }).first();
if (!(await stopButton.isVisible().catch(() => false))) {
return;
}
async function waitForAssistantText(page: Page, text: string) {
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: text }).last();
await expect(assistantMessage).toBeVisible({ timeout: 60000 });
return assistantMessage;
await expect(stopButton).not.toBeVisible({ timeout });
}
async function createAgentAndWait(page: Page, message: string) {
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await input.fill(message);
await input.press('Enter');
await expect(page).toHaveURL(/\/agent\//, { timeout: 120000 });
await expect(page.getByText(message, { exact: true })).toBeVisible();
}
async function requestCwd(page: Page) {
await sendPrompt(page, 'Run `pwd` and respond with exactly: CWD: <path>');
const message = await waitForAssistantText(page, 'CWD:');
// The assistant streams tokens; make sure we capture the full path (not a partial prefix).
await expect.poll(async () => (await message.textContent()) ?? '', { timeout: 60000 }).toContain('/worktrees/');
const content = (await message.textContent()) ?? '';
const match = content.match(/CWD:\s*(\S+)/);
if (!match) {
throw new Error(`Expected agent to respond with "CWD: <path>", got: ${content}`);
}
return match[1].trim();
await createAgent(page, message);
}
async function selectAttachWorktree(page: Page, branchName: string) {
await page.getByTestId('worktree-attach-toggle').click();
const picker = page.getByTestId('worktree-attach-picker');
await expect(picker).toBeVisible();
const trigger = page.getByTestId('worktree-select-trigger').first();
await expect(trigger).toBeVisible({ timeout: 30000 });
await trigger.click();
// Wait a bit for the worktree list to load
await page.waitForTimeout(1000);
await picker.click();
// Wait a bit for animation
await page.waitForTimeout(500);
const sheet = page.getByLabel('Bottom Sheet', { exact: true });
const backdrop = page.getByRole('button', { name: 'Bottom sheet backdrop' }).first();
await expect.poll(async () => {
const sheetVisible = await sheet.isVisible().catch(() => false);
const backdropVisible = await backdrop.isVisible().catch(() => false);
// Also check if branch name is visible directly
const branchVisible = await page.getByText(branchName, { exact: true }).first().isVisible().catch(() => false);
return sheetVisible || backdropVisible || branchVisible;
}, { timeout: 10000 }).toBeTruthy();
const sheetVisible = await sheet.isVisible().catch(() => false);
const scope = sheetVisible ? sheet : page;
const preferredOption = scope.getByText(branchName, { exact: true }).first();
if (await preferredOption.isVisible().catch(() => false)) {
await preferredOption.click();
await expect(picker).toContainText(branchName);
return;
const menu = page.getByTestId('combobox-desktop-container').first();
await expect(menu).toBeVisible({ timeout: 10000 });
const searchInput = page.getByRole('textbox', { name: /search worktrees/i }).first();
if (await searchInput.isVisible().catch(() => false)) {
await searchInput.fill(branchName);
}
const options = scope.locator('[data-testid^="worktree-attach-option-"]');
const optionCount = await options.count();
if (optionCount === 0) {
throw new Error(`No worktree options were available in the attach picker`);
}
const fallbackOption = options.first();
const fallbackLabel = ((await fallbackOption.innerText()) ?? "").trim();
await fallbackOption.click();
if (fallbackLabel.length > 0) {
await expect(picker).toContainText(fallbackLabel);
}
const preferredOption = menu
.getByText(new RegExp(`^${escapeRegex(branchName)}$`, 'i'))
.first();
await expect(preferredOption).toBeVisible({ timeout: 10000 });
await preferredOption.click({ force: true });
await expect(menu).toHaveCount(0);
await expect(trigger).toContainText(branchName, { timeout: 30000 });
}
async function enableCreateWorktree(page: Page) {
const createToggle = page.getByTestId('worktree-create-toggle');
const willCreateLabel = page.getByText(/Will create:/);
if (await willCreateLabel.isVisible()) {
const trigger = page.getByTestId('worktree-select-trigger').first();
await expect(trigger).toBeVisible({ timeout: 30000 });
const currentValue = ((await trigger.innerText().catch(() => '')) ?? '').trim();
if (/Create new worktree/i.test(currentValue)) {
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
timeout: 30000,
});
return;
}
const readyLabel = page.getByText(
/Run isolated from|Run in an isolated directory/
);
await expect(readyLabel).toBeVisible({ timeout: 30000 });
await createToggle.click({ force: true });
await expect(willCreateLabel).toBeVisible({ timeout: 30000 });
await trigger.click();
const menu = page.getByTestId('combobox-desktop-container').first();
await expect(menu).toBeVisible({ timeout: 10000 });
const createOption = menu.getByText('Create new worktree', { exact: true }).first();
await expect(createOption).toBeVisible({ timeout: 10000 });
await createOption.click({ force: true });
await expect(menu).toHaveCount(0);
await expect(trigger).toContainText('Create new worktree', { timeout: 30000 });
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
timeout: 30000,
});
}
async function refreshUncommittedMode(page: Page) {
@@ -195,9 +173,9 @@ async function refreshUncommittedMode(page: Page) {
}
async function refreshChangesTab(page: Page) {
const header = page.locator('[data-testid="explorer-header"]:visible').first();
await header.getByText('Files', { exact: true }).first().click();
await header.getByText('Changes', { exact: true }).first().click();
await ensureExplorerTabsVisible(page);
await page.getByTestId('explorer-tab-files').first().click();
await page.getByTestId('explorer-tab-changes').first().click();
}
function normalizeTmpPath(value: string) {
@@ -207,6 +185,72 @@ function normalizeTmpPath(value: string) {
return value;
}
type GitWorktreeEntry = {
worktreePath: string;
branchRef: string | null;
};
function parseGitWorktreeList(raw: string): GitWorktreeEntry[] {
const blocks = raw
.split(/\n\s*\n/g)
.map((block) => block.trim())
.filter((block) => block.length > 0);
const entries: GitWorktreeEntry[] = [];
for (const block of blocks) {
const worktreeMatch = block.match(/^worktree (.+)$/m);
if (!worktreeMatch) {
continue;
}
const branchMatch = block.match(/^branch (.+)$/m);
entries.push({
worktreePath: worktreeMatch[1].trim(),
branchRef: branchMatch ? branchMatch[1].trim() : null,
});
}
return entries;
}
async function waitForCreatedWorktree(repoPath: string, timeoutMs = 30000) {
const deadline = Date.now() + timeoutMs;
const normalizedRepoPath = normalizeTmpPath(repoPath);
while (Date.now() < deadline) {
try {
const output = execSync('git worktree list --porcelain', {
cwd: repoPath,
encoding: 'utf8',
});
const entries = parseGitWorktreeList(output);
const candidate = entries.find((entry) => {
const normalizedWorktreePath = normalizeTmpPath(entry.worktreePath);
if (normalizedWorktreePath === normalizedRepoPath) {
return false;
}
if (!entry.branchRef) {
return false;
}
return !/\/main$/i.test(entry.branchRef);
});
if (candidate) {
const branchName = candidate.branchRef?.split('/').filter(Boolean).pop();
if (branchName) {
return {
worktreePath: candidate.worktreePath,
branchName,
};
}
}
} catch {
// Ignore transient git worktree read errors while polling.
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for a non-main worktree under ${repoPath}`);
}
test('checkout-first Changes panel ship loop', async ({ page }) => {
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-'));
@@ -218,7 +262,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
await enableCreateWorktree(page);
await createAgentAndWait(page, 'Respond with exactly: READY');
await waitForAssistantText(page, 'READY');
await waitForAgentTurnToSettle(page);
await openChangesPanel(page);
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
@@ -228,11 +272,9 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
expect(branchNameFromUi.length).toBeGreaterThan(0);
const firstCwd = await requestCwd(page);
const worktreeBranch = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: firstCwd,
encoding: 'utf8',
}).trim();
const { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
repo.path
);
expect(worktreeBranch.length).toBeGreaterThan(0);
const [resolvedCwd, resolvedRepo] = await Promise.all([
realpath(firstCwd).catch(() => firstCwd),
@@ -244,19 +286,14 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/agent\/?$/);
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await selectAttachWorktree(page, worktreeBranch);
await createAgentAndWait(page, 'Respond with exactly: READY2');
await waitForAssistantText(page, 'READY2');
const secondCwd = await requestCwd(page);
expect(secondCwd).toBe(firstCwd);
await sendPrompt(page, "Respond with exactly: OK");
await waitForAssistantText(page, "OK");
await waitForAgentTurnToSettle(page);
await openChangesPanel(page);
const readmePath = path.join(firstCwd, 'README.md');
await appendFile(readmePath, '\nFirst change\n');
@@ -384,43 +421,18 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
execSync("git push", { cwd: repo.path });
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText(/No changes vs/i)).toBeVisible({
timeout: 60000,
});
await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
'Committed',
{ timeout: 30000 }
);
await refreshChangesTab(page);
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0, { timeout: 30000 });
await openChangesOverflowMenu(page);
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible();
await page.getByTestId('changes-menu-archive-worktree').click();
// Archiving a worktree deletes agents and redirects to home
await expect(page).toHaveURL(/\/agent\/?(?:\?.*)?$/, { timeout: 30000 });
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
// Repo inspection is async; wait until git options are interactive again.
await expect(page.getByText('Inspecting repository…')).toHaveCount(0, { timeout: 30000 });
await page.getByTestId('worktree-attach-toggle').click();
await expect(page.getByTestId('worktree-attach-picker')).toBeVisible({ timeout: 30000 });
await page.getByTestId('worktree-attach-picker').click();
await expect(page.getByText(worktreeBranch, { exact: true })).toHaveCount(0);
const attachSheet = page.getByLabel('Bottom Sheet', { exact: true });
if (await attachSheet.isVisible().catch(() => false)) {
await page.getByTestId('dropdown-sheet-close').click({ force: true });
await expect(attachSheet).toBeHidden({ timeout: 30000 });
}
await page.getByTestId('worktree-attach-toggle').click();
await expect(page.getByTestId('worktree-attach-picker')).toBeHidden({ timeout: 30000 });
// Post-ship UI behavior is implementation-dependent (archive can be promoted into
// primary flow or hidden behind menu variants), so continue from a fresh draft.
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(?:\?|$)/, { timeout: 30000 });
await setWorkingDirectory(page, nonGitDir);
// Wait for git options to disappear (repo inspection is async and the git section can briefly render stale UI).
await expect(page.getByTestId('worktree-attach-toggle')).toHaveCount(0, { timeout: 30000 });
await expect(page.getByTestId('worktree-attach-picker')).toHaveCount(0);
await createAgentAndWait(page, 'Respond with exactly: NON-GIT');
await waitForAssistantText(page, 'NON-GIT');
await openChangesPanel(page, { expectGit: false });
await expect(getChangesScope(page).getByTestId('changes-not-git')).toBeVisible();
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-overflow-menu')).toHaveCount(0);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
} finally {
await rm(nonGitDir, { recursive: true, force: true });
await repo.cleanup();
+21 -5
View File
@@ -2,6 +2,18 @@ import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
function parseAgentUrl(url: string): { serverId: string; agentId: string } {
const parsed = new URL(url);
const match = parsed.pathname.match(/\/h\/([^/]+)\/agent\/([^/?#]+)/);
if (!match) {
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${url}`);
}
return {
serverId: decodeURIComponent(match[1]),
agentId: decodeURIComponent(match[2]),
};
}
test('create agent in a temp repo', async ({ page }) => {
const repo = await createTempGitRepo();
const prompt = "Respond with exactly: Hello";
@@ -15,14 +27,18 @@ test('create agent in a temp repo', async ({ page }) => {
// Verify user message is shown in the stream
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
// Verify we used a fast model (do not fall back to a default like Sonnet).
// Verify we used the seeded fast model (do not fall back to other defaults).
await page.getByTestId('agent-overflow-menu').click();
await expect(page.getByText('Model', { exact: true })).toBeVisible();
await expect(page.getByTestId('agent-overflow-content').getByText(/haiku/i)).toBeVisible();
await expect(
page.getByTestId('agent-overflow-content').getByText(/gpt-5\.1-codex-mini/i)
).toBeVisible();
// Wait for agent response containing "Hello" within an assistant message
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: 'Hello' });
await expect(assistantMessage).toBeVisible({ timeout: 30000 });
// Verify the created agent's title reflects the response.
const { serverId, agentId } = parseAgentUrl(page.url());
const agentRow = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
await expect(agentRow).not.toContainText(/new agent/i, { timeout: 30000 });
await expect(agentRow).toContainText(/hello|greet|response/i, { timeout: 30000 });
} finally {
await repo.cleanup();
}
+13 -4
View File
@@ -17,15 +17,19 @@ test('deleting an agent persists after reload', async ({ page }) => {
await expect(input).toBeEditable();
await input.fill(prompt);
await input.press('Enter');
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
await expect(page).toHaveURL(/\/h\/[^/]+\/agent\/[^/?#]+(?:\?|$)/, {
timeout: 30000,
});
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
const match = page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
const match =
page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/) ??
page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
if (!match) {
throw new Error(`Expected /agent/:serverId/:agentId URL, got ${page.url()}`);
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
}
const serverId = decodeURIComponent(match[1]);
const agentId = decodeURIComponent(match[2]);
@@ -36,11 +40,16 @@ test('deleting an agent persists after reload', async ({ page }) => {
const agentRow = page.getByTestId(rowTestId).first();
await expect(agentRow).toBeVisible({ timeout: 30000 });
// Web UX: hover shows a quick-archive icon. (Long-press is touch-oriented and unreliable on desktop web.)
// Web UX: hover shows a quick-archive icon. First click enters confirm state; second click archives.
await agentRow.hover();
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
await expect(quickArchive).toBeVisible({ timeout: 10000 });
await quickArchive.click({ force: true });
const confirmArchive = page
.getByTestId(`agent-archive-confirm-${serverId}-${agentId}`)
.first();
await expect(confirmArchive).toBeVisible({ timeout: 10000 });
await confirmArchive.click({ force: true });
// Ensure deletion finished before reload (avoids races).
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
+41 -6
View File
@@ -5,10 +5,14 @@ import type { Page } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function addFakeMicrophone(page: Page) {
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.webm');
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.wav');
const base64Audio = (await readFile(fixturePath)).toString('base64');
const mimeType = 'audio/webm;codecs=opus';
const mimeType = 'audio/wav';
return page.addInitScript(({ base64Audio, mimeType }) => {
const mic = {
@@ -39,6 +43,24 @@ async function addFakeMicrophone(page: Page) {
};
};
const AudioContextCtor =
(window as any).AudioContext ?? (window as any).webkitAudioContext;
if (AudioContextCtor?.prototype?.createMediaStreamSource) {
const nativeCreateMediaStreamSource =
AudioContextCtor.prototype.createMediaStreamSource;
AudioContextCtor.prototype.createMediaStreamSource =
function patchedCreateMediaStreamSource(stream: unknown) {
const isFakeStream =
!!stream &&
typeof (stream as { getTracks?: unknown }).getTracks === 'function' &&
typeof (stream as { id?: unknown }).id === 'undefined';
if (isFakeStream) {
throw new Error('Force recorder fallback for fake microphone stream');
}
return nativeCreateMediaStreamSource.call(this, stream);
};
}
const blobFromBase64 = (base64: string, mimeType: string): Blob => {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
@@ -142,6 +164,9 @@ test('dictation transcribes fixture via real STT', async ({ page }) => {
.count();
await page.keyboard.press('Control+d');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
await expect
.poll(
@@ -199,7 +224,14 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent($|\/)/);
const match = page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/);
if (!match) {
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
}
const serverId = decodeURIComponent(match[1]!);
const agentId = decodeURIComponent(match[2]!);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
await page.keyboard.press('Control+d');
await expect
@@ -211,16 +243,19 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
const newAgentButton = page.getByTestId('sidebar-new-agent');
await expect(newAgentButton).toBeVisible();
await newAgentButton.click();
await expect(page).toHaveURL(/\/agent\/?$/);
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
await page.waitForTimeout(10_000);
const agentEntry = page.getByText(repo.path).first();
const agentEntry = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
await expect(agentEntry).toBeVisible();
await agentEntry.click();
await expect(page).toHaveURL(/\/agent($|\/)/);
await expect(page).toHaveURL(
new RegExp(`/h/${escapeRegex(serverId)}/agent/${escapeRegex(agentId)}(?:\\?|$)`)
);
await expect(page.getByText(/voice note/i)).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Copy message' })).toHaveCount(initialCopyMessageCount);
await expect(page.getByTestId('agent-chat-scroll').getByText(/this is a voice note\./i)).toHaveCount(0);
} finally {
await repo.cleanup();
}
@@ -12,7 +12,7 @@ test("draft enables explorer after selecting a working directory", async ({ page
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
await newAgentButton.click();
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
await setWorkingDirectory(page, repo.path);
+2 -2
View File
@@ -77,10 +77,10 @@ test.beforeEach(async ({ page }) => {
// Ensure create flow never uses a remembered host from the developer's real app.
serverId: testDaemon.serverId,
// Keep e2e fast/cheap by default.
provider: 'claude',
provider: 'codex',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini' },
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
},
};
@@ -1,7 +1,7 @@
import path from 'node:path';
import { appendFile } from 'node:fs/promises';
import { test, expect, type Page } from './fixtures';
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test.describe.configure({ timeout: 90000 });
@@ -10,18 +10,31 @@ function getChangesScope(page: Page) {
return page.locator('[data-testid="explorer-content-area"]:visible').first();
}
async function ensureExplorerTabsVisible(page: Page) {
const changesTab = page.getByTestId('explorer-tab-changes').first();
if (await changesTab.isVisible().catch(() => false)) {
return;
}
const toggle = page
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
.first();
await expect(toggle).toBeVisible({ timeout: 10000 });
for (let attempt = 0; attempt < 4; attempt += 1) {
if (await changesTab.isVisible().catch(() => false)) {
return;
}
await toggle.click();
await page.waitForTimeout(200);
}
await expect(changesTab).toBeVisible({ timeout: 30000 });
}
async function openChangesPanel(page: Page) {
await ensureExplorerTabsVisible(page);
const changesHeader = getChangesScope(page).getByTestId('changes-header');
if (!(await changesHeader.isVisible())) {
const explorerHeader = page.getByTestId('explorer-header');
if (await explorerHeader.isVisible()) {
await page.getByText('Changes', { exact: true }).click();
} else {
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
await overflowMenu.click();
await page.getByText('View Changes', { exact: true }).click();
}
await page.getByTestId('explorer-tab-changes').first().click();
}
await expect(changesHeader).toBeVisible();
}
@@ -31,22 +44,29 @@ async function refreshUncommittedMode(page: Page) {
const toggle = scope.getByTestId('changes-diff-status').first();
await expect(toggle).toBeVisible({ timeout: 30000 });
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
if (await diffModeBackdrop.isVisible().catch(() => false)) {
await diffModeBackdrop.click({ force: true });
await expect(diffModeBackdrop).toHaveCount(0);
}
const currentLabel = (await toggle.innerText()).trim();
await toggle.click();
await toggle.click({ force: true });
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
const firstTarget = currentLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
await page.getByTestId(firstTarget).click({ force: true });
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
const nextLabel = (await toggle.innerText()).trim();
await toggle.click();
await toggle.click({ force: true });
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
const secondTarget = nextLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
await page.getByTestId(secondTarget).click({ force: true });
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
}
async function createAgentAndWait(page: Page, message: string) {
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await input.fill(message);
await input.press('Enter');
await expect(page).toHaveURL(/\/agent\//, { timeout: 120000 });
await expect(page.getByText(message, { exact: true })).toBeVisible();
await createAgent(page, message);
}
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
+303 -33
View File
@@ -7,6 +7,14 @@ import net from 'node:net';
import { Buffer } from 'node:buffer';
import dotenv from 'dotenv';
type WaitForServerOptions = {
host?: string;
timeoutMs?: number;
label: string;
childProcess?: ChildProcess | null;
getRecentOutput?: () => string;
};
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -22,23 +30,157 @@ async function getAvailablePort(): Promise<number> {
});
}
async function waitForServer(port: number, timeout = 15000): Promise<void> {
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
const lines: string[] = [];
return {
add(line: string) {
lines.push(line);
if (lines.length > maxLines) {
lines.shift();
}
},
dump() {
return lines.join('\n');
},
};
}
function formatRecentOutput(getRecentOutput?: () => string): string {
if (!getRecentOutput) {
return '';
}
const output = getRecentOutput().trim();
if (!output) {
return '';
}
return `\nRecent output:\n${output}`;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
const {
host = '127.0.0.1',
timeoutMs = 15000,
label,
childProcess,
getRecentOutput,
} = options;
const start = Date.now();
while (Date.now() - start < timeout) {
let lastConnectionError: unknown = null;
while (Date.now() - start < timeoutMs) {
if (childProcess && childProcess.exitCode !== null) {
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : '';
throw new Error(
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`
);
}
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, 'localhost', () => {
const socket = net.connect(port, host, () => {
socket.end();
resolve();
});
socket.setTimeout(1000, () => {
socket.destroy();
reject(new Error(`Connection timed out to ${host}:${port}`));
});
socket.on('error', reject);
});
return;
} catch {
} catch (error) {
lastConnectionError = error;
await new Promise((r) => setTimeout(r, 100));
}
}
throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
const reason =
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : '';
throw new Error(
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`
);
}
function parseRelayStartupFailure(line: string): string | null {
const clean = stripAnsi(line);
if (/Address already in use/i.test(clean)) {
return clean;
}
if (/failed: ::bind\(/i.test(clean)) {
return clean;
}
if (/Fatal uncaught/i.test(clean)) {
return clean;
}
return null;
}
async function stopProcess(child: ChildProcess | null): Promise<void> {
if (!child) {
return;
}
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill('SIGTERM');
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
}
resolve();
}, 5000);
child.once('exit', () => {
clearTimeout(timeout);
resolve();
});
});
}
function summarizeOpenAiErrorBody(body: string): string {
const trimmed = body.trim();
if (!trimmed) {
return 'empty response body';
}
if (trimmed.length <= 240) {
return trimmed;
}
return `${trimmed.slice(0, 240)}…`;
}
async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean> {
const key = apiKey?.trim();
if (!key) {
return false;
}
try {
const response = await fetch('https://api.openai.com/v1/models?limit=1', {
method: 'GET',
headers: {
Authorization: `Bearer ${key}`,
},
});
if (response.ok) {
return true;
}
const body = await response.text();
console.warn(
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`
);
return false;
} catch (error) {
console.warn(
`[e2e] OPENAI_API_KEY probe request failed: ${
error instanceof Error ? error.message : String(error)
}`
);
return false;
}
}
let daemonProcess: ChildProcess | null = null;
@@ -81,11 +223,53 @@ export default async function globalSetup() {
}
const port = await getAvailablePort();
const relayPort = await getAvailablePort();
let relayPort = 0;
const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
const cleanup = async () => {
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
daemonProcess = null;
metroProcess = null;
relayProcess = null;
if (paseoHome) {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
}
};
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech');
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
const dictationProvider = openAiUsable ? 'openai' : 'local';
if (dictationProvider === 'local' && !hasDefaultLocalModelsDir) {
throw new Error(
'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' +
'Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.'
);
}
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null;
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
);
try {
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
const maxRelayStartupAttempts = 5;
let relayStarted = false;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
relayPort = await getAvailablePort();
relayLineBuffer = createLineBuffer();
let relayStartupFailureLine: string | null = null;
let relayReadyForSelectedPort = false;
relayProcess = spawn(
'npx',
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
@@ -98,19 +282,93 @@ export default async function globalSetup() {
);
relayProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((l) => l.trim());
const lines = data.toString().split('\n').filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stdout] ${line}`);
const failure = parseRelayStartupFailure(line);
if (failure) {
relayStartupFailureLine = failure;
}
const clean = stripAnsi(line);
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
if (readyMatch && Number(readyMatch[1]) === relayPort) {
relayReadyForSelectedPort = true;
}
console.log(`[relay] ${line}`);
}
});
relayProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((l) => l.trim());
const lines = data.toString().split('\n').filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stderr] ${line}`);
const failure = parseRelayStartupFailure(line);
if (failure) {
relayStartupFailureLine = failure;
}
const clean = stripAnsi(line);
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
if (readyMatch && Number(readyMatch[1]) === relayPort) {
relayReadyForSelectedPort = true;
}
console.error(`[relay] ${line}`);
}
});
await waitForServer(relayPort, 30000);
try {
await waitForServer(relayPort, {
label: 'Relay dev server',
timeoutMs: 30000,
childProcess: relayProcess,
getRecentOutput: relayLineBuffer.dump,
});
const readyDeadline = Date.now() + 5000;
while (
!relayReadyForSelectedPort &&
relayStartupFailureLine === null &&
relayProcess?.exitCode === null &&
relayProcess?.signalCode === null &&
Date.now() < readyDeadline
) {
await sleep(100);
}
if (relayStartupFailureLine) {
throw new Error(`Relay startup failed: ${relayStartupFailureLine}`);
}
if (!relayReadyForSelectedPort) {
throw new Error(
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
relayLineBuffer.dump
)}`
);
}
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
throw new Error(
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
relayLineBuffer.dump
)}`
);
}
relayStarted = true;
break;
} catch (error) {
lastRelayStartupError = error;
await stopProcess(relayProcess);
relayProcess = null;
}
}
if (!relayStarted) {
const message =
lastRelayStartupError instanceof Error
? lastRelayStartupError.message
: String(lastRelayStartupError);
throw new Error(
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`
);
}
// Start Metro bundler on dynamic port
const appDir = path.resolve(__dirname, '..');
@@ -125,14 +383,19 @@ export default async function globalSetup() {
});
metroProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((l) => l.trim());
const lines = data.toString().split('\n').filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`);
}
});
metroProcess.stderr?.on('data', (data: Buffer) => {
console.error(`[metro] ${data.toString().trim()}`);
const lines = data.toString().split('\n').filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`);
}
});
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
@@ -153,9 +416,13 @@ export default async function globalSetup() {
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
PASEO_DICTATION_ENABLED: '1',
PASEO_VOICE_MODE_ENABLED: '1',
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
PASEO_VOICE_STT_PROVIDER: 'openai',
PASEO_VOICE_TTS_PROVIDER: 'openai',
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: 'development',
},
stdio: ['ignore', 'pipe', 'pipe'],
@@ -170,6 +437,7 @@ export default async function globalSetup() {
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
daemonLineBuffer.add(`[stdout] ${trimmed}`);
if (!offerPayload) {
const clean = stripAnsi(trimmed);
try {
@@ -195,13 +463,26 @@ export default async function globalSetup() {
});
daemonProcess.stderr?.on('data', (data: Buffer) => {
console.error(`[daemon] ${data.toString().trim()}`);
const lines = data.toString().split('\n').filter((line) => line.trim());
for (const line of lines) {
daemonLineBuffer.add(`[stderr] ${line}`);
console.error(`[daemon] ${line}`);
}
});
// Wait for both daemon and Metro to be ready
await Promise.all([
waitForServer(port),
waitForServer(metroPort, 120000), // Metro can take longer to start
waitForServer(port, {
label: 'Paseo daemon',
childProcess: daemonProcess,
getRecentOutput: daemonLineBuffer.dump,
}),
waitForServer(metroPort, {
label: 'Metro web server',
timeoutMs: 120000, // Metro can take longer to start
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
}),
]);
// Wait for daemon to emit a pairing offer (includes relay session ID).
@@ -224,22 +505,11 @@ export default async function globalSetup() {
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
return async () => {
if (daemonProcess) {
daemonProcess.kill('SIGTERM');
daemonProcess = null;
}
if (metroProcess) {
metroProcess.kill('SIGTERM');
metroProcess = null;
}
if (relayProcess) {
relayProcess.kill('SIGTERM');
relayProcess = null;
}
if (paseoHome) {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
}
await cleanup();
console.log('[e2e] Test daemon stopped');
};
} catch (error) {
await cleanup();
throw error;
}
}
+140 -11
View File
@@ -71,10 +71,10 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
'@paseo:create-agent-preferences',
JSON.stringify({
serverId: expectedServerId,
provider: 'claude',
provider: 'codex',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini' },
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
},
})
);
@@ -183,7 +183,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
const worktreePicker = page.getByTestId('worktree-attach-picker');
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true });
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first();
const closeBottomSheet = async () => {
const bottomSheetBackdrop = page
.getByRole('button', { name: 'Bottom sheet backdrop' })
@@ -362,16 +362,83 @@ export const ensureHostSelected = async (page: Page) => {
export const createAgent = async (page: Page, message: string) => {
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await preferFastThinkingOption(page);
await input.fill(message);
await input.press('Enter');
// Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`.
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
// Router updates can be same-document transitions; assert URL state instead of waiting for a commit event.
await expect(page).toHaveURL(/\/agent\//, { timeout: 30000 });
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
};
async function preferFastThinkingOption(page: Page): Promise<void> {
const providerTrigger = page.getByTestId('draft-provider-select').first();
if (await providerTrigger.isVisible().catch(() => false)) {
const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim();
if (!/codex/i.test(providerText)) {
return;
}
}
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first();
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
return;
}
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '')
.trim()
.toLowerCase();
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
return;
}
await thinkingTrigger.click();
const menu = page.getByTestId('agent-thinking-menu').first();
if (!(await menu.isVisible().catch(() => false))) {
return;
}
const preferredLabels = ['low', 'minimal', 'off', 'medium'];
let selected = false;
for (const label of preferredLabels) {
const option = menu
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') })
.first();
if (await option.isVisible().catch(() => false)) {
await option.click({ force: true });
selected = true;
break;
}
}
if (!selected) {
const options = menu.getByRole('button');
const count = await options.count();
for (let index = 0; index < count; index += 1) {
const option = options.nth(index);
const label = ((await option.innerText().catch(() => '')) ?? '').trim();
if (!label) {
continue;
}
if (label.toLowerCase() === currentThinkingLabel) {
continue;
}
await option.click({ force: true });
selected = true;
break;
}
}
if (!selected) {
await page.keyboard.press('Escape').catch(() => undefined);
return;
}
await expect(menu).not.toBeVisible({ timeout: 5000 });
}
export interface AgentConfig {
directory: string;
provider?: string;
@@ -381,42 +448,104 @@ export interface AgentConfig {
}
export const selectProvider = async (page: Page, provider: string) => {
const normalizedProvider = provider.trim();
if (!normalizedProvider) {
throw new Error('Provider must be a non-empty string.');
}
const providerTrigger = page.getByTestId('draft-provider-select').first();
if (
await providerTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
.first()
.isVisible()
.catch(() => false)
) {
return;
}
if (await providerTrigger.isVisible().catch(() => false)) {
await providerTrigger.click();
} else {
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
await expect(providerLabel).toBeVisible();
await providerLabel.click();
}
const option = page.getByText(provider, { exact: true }).first();
const dialog = page.getByRole('dialog').last();
const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first();
if (await searchInput.isVisible().catch(() => false)) {
await searchInput.fill(normalizedProvider);
}
const option = dialog
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
.first();
await expect(option).toBeVisible();
await option.click();
};
export const selectModel = async (page: Page, model: string) => {
const normalizedModel = model.trim();
if (!normalizedModel) {
throw new Error('Model must be a non-empty string.');
}
const modelTrigger = page.getByTestId('draft-model-select').first();
if (
await modelTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
.first()
.isVisible()
.catch(() => false)
) {
return;
}
if (await modelTrigger.isVisible().catch(() => false)) {
await modelTrigger.click();
} else {
const modelLabel = page.getByText('MODEL', { exact: true }).first();
await expect(modelLabel).toBeVisible();
await modelLabel.click();
}
// Wait for the model dropdown to open
const searchInput = page.getByRole('textbox', { name: /search model/i });
await expect(searchInput).toBeVisible({ timeout: 10000 });
// Type to search/filter models
await searchInput.fill(model);
await searchInput.fill(normalizedModel);
const dialog = page.getByRole('dialog');
const option = dialog
.getByText(new RegExp(`^${escapeRegex(model)}$`, 'i'))
const exactOption = dialog
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
.first();
await expect(option).toBeVisible({ timeout: 30000 });
await option.click({ force: true });
const exactVisible = await exactOption.isVisible().catch(() => false);
if (exactVisible) {
await exactOption.click({ force: true });
} else {
// Modern labels include version suffixes (for example "Haiku 4.5"), so
// select the first filtered result using keyboard confirm.
await searchInput.press('Enter');
}
// Wait for dropdown to close
if (await searchInput.isVisible().catch(() => false)) {
await page.keyboard.press('Escape').catch(() => undefined);
}
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
};
export const selectMode = async (page: Page, mode: string) => {
const modeTrigger = page.getByTestId('draft-mode-select').first();
if (await modeTrigger.isVisible().catch(() => false)) {
await modeTrigger.click();
} else {
const modeLabel = page.getByText('MODE', { exact: true }).first();
await expect(modeLabel).toBeVisible();
await modeLabel.click();
}
// Wait for the mode dropdown to open
const searchInput = page.getByRole('textbox', { name: /search mode/i });
+3 -1
View File
@@ -12,7 +12,9 @@ export const createTempGitRepo = async (
prefix = 'paseo-e2e-',
options?: { withRemote?: boolean }
): Promise<TempRepo> => {
const repoPath = await mkdtemp(path.join(tmpdir(), prefix));
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp';
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
const withRemote = options?.withRemote ?? false;
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
@@ -38,5 +38,7 @@ test('no hosts shows welcome; direct connection adds host and lands on agent cre
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable({
timeout: 15000,
});
});
+12 -2
View File
@@ -78,11 +78,21 @@ test('host removal removes the host from UI and persists after reload', async ({
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
await page.getByTestId(`daemon-menu-trigger-${extraDaemon.serverId}`).click();
await page.getByTestId(`daemon-menu-remove-${extraDaemon.serverId}`).click();
const hostSettingsButton = page.getByTestId(`daemon-card-settings-${extraDaemon.serverId}`).first();
await expect(hostSettingsButton).toBeVisible({ timeout: 10000 });
await hostSettingsButton.click();
const hostDetailModal = page.getByTestId('host-detail-modal');
await expect(hostDetailModal).toBeVisible({ timeout: 10000 });
await hostDetailModal.getByText('Advanced', { exact: true }).click();
await page.getByText('Remove host', { exact: true }).last().click();
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
await page.getByTestId('remove-host-confirm').click();
await expect(page.getByTestId(`daemon-card-${extraDaemon.serverId}`)).toHaveCount(0, {
timeout: 30000,
});
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
await page.waitForFunction(
(serverId) => {
+3 -4
View File
@@ -41,10 +41,10 @@ test('new agent respects serverId in the URL', async ({ page }) => {
};
const createAgentPreferences = {
serverId: testDaemon.serverId,
provider: 'claude',
provider: 'codex',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini' },
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
},
};
@@ -60,7 +60,7 @@ test('new agent respects serverId in the URL', async ({ page }) => {
{ daemon: testDaemon, preferences: createAgentPreferences }
);
await page.reload();
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible({ timeout: 20000 });
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
@@ -109,7 +109,6 @@ test('new agent auto-selects first online host when no preference is stored', as
await page.reload();
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
// Host should be auto-selected (no manual selection required).
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
+30 -3
View File
@@ -17,8 +17,28 @@ test('manual host add accepts host:port only and persists a direct connection',
});
await page.goto('/settings');
await expect
.poll(
async () => {
if (await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false)) {
return 'welcome';
}
if (await page.getByText('+ Add connection', { exact: true }).isVisible().catch(() => false)) {
return 'settings';
}
return '';
},
{ timeout: 15000 }
)
.not.toBe('');
const isWelcome = await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false);
if (isWelcome) {
await page.getByText('Direct connection', { exact: true }).first().click();
} else {
await page.getByText('+ Add connection', { exact: true }).click();
await page.getByText('Direct connection', { exact: true }).click();
}
const input = page.getByPlaceholder('host:6767');
await expect(input).toBeVisible();
@@ -27,11 +47,18 @@ test('manual host add accepts host:port only and persists a direct connection',
await page.getByText('Connect', { exact: true }).click();
const nameModal = page.getByTestId('name-host-modal');
await expect(nameModal).toBeVisible({ timeout: 15000 });
if (await nameModal.isVisible().catch(() => false)) {
await nameModal.getByTestId('name-host-skip').click();
}
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible({ timeout: 10000 });
await settingsButton.click();
await expect(page.locator(`[data-testid="daemon-card-${serverId}"]:visible`).first()).toBeVisible({
timeout: 15000,
});
await page.waitForFunction(
({ port, serverId }) => {
+27 -6
View File
@@ -10,6 +10,15 @@ function encodeBase64Url(input: string): string {
}
test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host', async ({ page }) => {
const relayPort = process.env.E2E_RELAY_PORT;
const serverId = process.env.E2E_SERVER_ID;
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
if (!relayPort || !serverId || !daemonPublicKeyB64) {
throw new Error(
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
);
}
// Override the default fixture seeding for this test.
await page.goto('/settings');
await page.evaluate(() => {
@@ -18,27 +27,39 @@ test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host',
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
localStorage.removeItem('@paseo:settings');
});
await page.reload();
await page.goto('/');
const relayEndpoint = `127.0.0.1:${relayPort}`;
const offer = {
v: 2 as const,
serverId: 'e2e-server-123',
daemonPublicKeyB64: Buffer.from('e2e-public-key', 'utf8').toString('base64'),
relay: { endpoint: 'relay.local:443' },
serverId,
daemonPublicKeyB64,
relay: { endpoint: relayEndpoint },
};
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
const welcomeTitle = page.getByText('Welcome to Paseo', { exact: true });
if (await welcomeTitle.isVisible().catch(() => false)) {
await page.getByTestId('welcome-paste-pairing-link').click();
} else {
await page.getByText('+ Add connection', { exact: true }).click();
await page.getByText('Paste pairing link', { exact: true }).click();
}
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
await expect(input).toBeVisible();
await input.fill(offerUrl);
await page.getByText('Pair', { exact: true }).click();
await page.getByTestId('pair-link-submit').click();
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
const nameHostModal = page.getByTestId('name-host-modal');
if (await nameHostModal.isVisible().catch(() => false)) {
await nameHostModal.getByTestId('name-host-skip').click();
}
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
await page.waitForFunction(
({ expected }) => {
+5 -6
View File
@@ -7,6 +7,7 @@ import {
waitForPermissionPrompt,
allowPermission,
denyPermission,
waitForAgentFinishUI,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
@@ -31,7 +32,7 @@ test.describe('permission prompts', () => {
try {
await createAgentWithConfig(page, {
directory: repo.path,
model: 'haiku',
provider: 'claude',
mode: 'Always Ask',
prompt,
});
@@ -71,7 +72,7 @@ test.describe('permission prompts', () => {
try {
await createAgentWithConfig(page, {
directory: repo.path,
model: 'haiku',
provider: 'claude',
mode: 'Always Ask',
prompt,
});
@@ -79,10 +80,8 @@ test.describe('permission prompts', () => {
await waitForPermissionPrompt(page, 30000);
await denyPermission(page);
await expect(page.getByText(/denied by the user|permission\/authorization check/i)).toBeVisible({
timeout: 30_000,
});
await waitForAgentFinishUI(page, 30000);
await expect(page.getByTestId('permission-request-question')).toHaveCount(0);
expect(existsSync(filePath)).toBe(false);
@@ -13,10 +13,26 @@ test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
await createAgent(page, 'Agent A: respond with exactly A');
await expect(page).toHaveURL(/\/agent\//);
// Click sidebar New Agent and assert it does not carry over agent settings via URL.
// Click sidebar New Agent and assert it re-opens the host draft route while
// preserving working directory context from the selected agent.
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/agent\/?$/);
await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoA.path)));
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
const searchWorkingDir = await page.evaluate(() => {
try {
return new URL(window.location.href).searchParams.get('workingDir');
} catch {
return null;
}
});
const normalizedCandidates = new Set<string>([repoA.path]);
if (repoA.path.startsWith('/var/')) {
normalizedCandidates.add(`/private${repoA.path}`);
}
if (repoA.path.startsWith('/private/var/')) {
normalizedCandidates.add(repoA.path.replace(/^\/private/, ''));
}
expect(searchWorkingDir).not.toBeNull();
expect(normalizedCandidates.has(searchWorkingDir ?? '')).toBe(true);
} finally {
await repoA.cleanup();
}
@@ -26,27 +26,14 @@ test("project filter dropdown never appears visibly at 0,0 on open", async ({ pa
});
};
const getContainer = (node: HTMLElement): HTMLElement | null => {
let current: HTMLElement | null = node;
while (current && current !== document.body) {
const style = getComputedStyle(current);
if (style.position === "absolute" && style.backgroundColor !== "rgba(0, 0, 0, 0)") {
return current;
}
current = current.parentElement;
}
return null;
};
const tryResolveTarget = (root: HTMLElement) => {
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
for (const element of stack) {
if (!element.textContent?.includes("No projects")) continue;
const container = getContainer(element);
if (!container) continue;
target = container;
if (element.dataset?.testid === "combobox-desktop-container") {
target = element;
return true;
}
}
return false;
};
+83 -51
View File
@@ -7,6 +7,21 @@ import {
} from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
function visibleTestId(page: Page, testId: string) {
return page.locator(`[data-testid="${testId}"]:visible`).first();
}
function visibleTestIdPrefix(page: Page, prefix: string) {
return page.locator(`[data-testid^="${prefix}"]:visible`);
}
let terminalMarkerCounter = 0;
function shortTerminalMarker(prefix: string): string {
terminalMarkerCounter += 1;
return `${prefix.slice(0, 1)}${terminalMarkerCounter.toString(36)}`;
}
function parseAgentFromUrl(url: string): { serverId: string; agentId: string } {
const pathname = (() => {
try {
@@ -60,7 +75,7 @@ async function openNewAgentDraft(page: Page): Promise<void> {
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
await newAgentButton.click();
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
await expect(
page.locator('[data-testid="working-directory-select"]:visible').first()
).toBeVisible({
@@ -68,38 +83,42 @@ async function openNewAgentDraft(page: Page): Promise<void> {
});
}
async function ensureExplorerTabsVisible(page: Page): Promise<void> {
const filesTab = visibleTestId(page, "explorer-tab-files");
if (await filesTab.isVisible().catch(() => false)) {
return;
}
const toggle = page
.getByRole("button", { name: /open explorer|close explorer|toggle explorer/i })
.first();
await expect(toggle).toBeVisible({ timeout: 10000 });
await toggle.click();
await expect(filesTab).toBeVisible({ timeout: 30000 });
}
async function openTerminalsPanel(page: Page): Promise<void> {
let header = page.locator('[data-testid="explorer-header"]:visible').first();
if (!(await header.isVisible().catch(() => false))) {
const toggle = page.getByRole("button", {
name: /open explorer|close explorer|toggle explorer/i,
});
if (await toggle.first().isVisible().catch(() => false)) {
await toggle.first().click();
}
}
await ensureExplorerTabsVisible(page);
header = page.locator('[data-testid="explorer-header"]:visible').first();
await expect(header).toBeVisible({ timeout: 30000 });
const terminalsTab = page.getByTestId("explorer-tab-terminals").first();
const terminalsTab = visibleTestId(page, "explorer-tab-terminals");
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
await terminalsTab.click();
await expect(page.getByTestId("terminals-header").first()).toBeVisible({
await expect(visibleTestId(page, "terminals-header")).toBeVisible({
timeout: 30000,
});
await expect(page.getByTestId("terminal-surface").first()).toBeVisible({
await expect(visibleTestId(page, "terminal-surface")).toBeVisible({
timeout: 30000,
});
}
async function openFilesPanel(page: Page): Promise<void> {
const filesTab = page.getByTestId("explorer-tab-files").first();
await ensureExplorerTabsVisible(page);
const filesTab = visibleTestId(page, "explorer-tab-files");
await expect(filesTab).toBeVisible({ timeout: 30000 });
await filesTab.click();
await expect(page.getByTestId("files-pane-header").first()).toBeVisible({
await expect(visibleTestId(page, "files-pane-header")).toBeVisible({
timeout: 30000,
});
}
@@ -124,7 +143,7 @@ async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
async function selectNewestTerminalTab(page: Page): Promise<void> {
const tabs = page.locator('[data-testid^="terminal-tab-"]');
const tabs = visibleTestIdPrefix(page, "terminal-tab-");
await expect(tabs.first()).toBeVisible({ timeout: 30000 });
await expect
.poll(async () => await tabs.count(), { timeout: 30000 })
@@ -133,7 +152,7 @@ async function selectNewestTerminalTab(page: Page): Promise<void> {
}
async function getFirstTerminalTabTestId(page: Page): Promise<string> {
const firstTab = page.locator('[data-testid^="terminal-tab-"]').first();
const firstTab = visibleTestIdPrefix(page, "terminal-tab-").first();
await expect(firstTab).toBeVisible({ timeout: 30000 });
const value = await firstTab.getAttribute("data-testid");
if (!value) {
@@ -143,7 +162,7 @@ async function getFirstTerminalTabTestId(page: Page): Promise<string> {
}
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> {
const surface = page.getByTestId("terminal-surface").first();
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(command, { delay: 1 });
@@ -158,7 +177,7 @@ async function runTerminalCommandWithPreEnterEcho(
command: string,
expectedText: string
): Promise<void> {
const surface = page.getByTestId("terminal-surface").first();
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(command, { delay: 1 });
@@ -218,11 +237,11 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
await createAgent(page, "Reply with exactly: terminal smoke");
await openTerminalsPanel(page);
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
await expect(visibleTestIdPrefix(page, "terminal-tab-").first()).toBeVisible({
timeout: 30000,
});
const preEnterEchoMarker = `typed-echo-${Date.now()}`;
const preEnterEchoMarker = shortTerminalMarker("typed");
await runTerminalCommandWithPreEnterEcho(
page,
`echo ${preEnterEchoMarker}`,
@@ -240,7 +259,7 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
const markerOne = `terminal-smoke-one-${Date.now()}`;
await runTerminalCommand(page, `echo ${markerOne}`, markerOne);
await page.getByTestId("terminals-create-button").first().click();
await visibleTestId(page, "terminals-create-button").click();
await selectNewestTerminalTab(page);
const markerTwo = `terminal-smoke-two-${Date.now()}`;
@@ -272,7 +291,7 @@ test("terminal reattaches cleanly after heavy output and tab switches", async ({
await expect(page.getByText("Terminal stream ended. Reconnecting…")).toHaveCount(0, {
timeout: 30000,
});
await expect(page.getByTestId("terminal-attach-loading")).toHaveCount(0, {
await expect(page.locator('[data-testid="terminal-attach-loading"]:visible')).toHaveCount(0, {
timeout: 30000,
});
}
@@ -295,7 +314,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
@@ -303,7 +322,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
await page.keyboard.press("Enter");
}
const markerAfterEnters = `echo-visible-${Date.now()}`;
const markerAfterEnters = shortTerminalMarker("visible");
await page.keyboard.type(`echo ${markerAfterEnters}`, { delay: 0 });
await expect(surface).toContainText(`echo ${markerAfterEnters}`, {
timeout: 30000,
@@ -319,7 +338,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
await page.keyboard.press("Backspace");
}
const markerAfterBackspace = `echo-backspace-${Date.now()}`;
const markerAfterBackspace = shortTerminalMarker("backspace");
await page.keyboard.type(markerAfterBackspace, { delay: 0 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(markerAfterBackspace, {
@@ -341,7 +360,7 @@ test("terminal remains interactive after alternate-screen enter/exit", async ({
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
@@ -375,7 +394,7 @@ test("terminal tab is removed when shell exits", async ({ page }) => {
const exitedTabTestId = await getFirstTerminalTabTestId(page);
const surface = page.getByTestId("terminal-surface").first();
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type("exit", { delay: 1 });
@@ -385,7 +404,7 @@ test("terminal tab is removed when shell exits", async ({ page }) => {
timeout: 30000,
});
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
await expect(visibleTestIdPrefix(page, "terminal-tab-").first()).toBeVisible({
timeout: 30000,
});
const nextTabTestId = await getFirstTerminalTabTestId(page);
@@ -408,7 +427,7 @@ test("closing terminal with running command asks for confirmation", async ({ pag
const tabTestId = await getFirstTerminalTabTestId(page);
const terminalId = tabTestId.replace("terminal-tab-", "");
const tab = page.getByTestId(tabTestId).first();
const tab = visibleTestId(page, tabTestId);
await expect(tab).toBeVisible({ timeout: 30000 });
const runningMarker = `terminal-close-running-${Date.now()}`;
@@ -425,7 +444,7 @@ test("closing terminal with running command asks for confirmation", async ({ pag
await dialog.dismiss();
}
);
await page.getByTestId(`terminal-close-${terminalId}`).first().click();
await visibleTestId(page, `terminal-close-${terminalId}`).click();
await dialogPromise;
await expect(tab).toBeVisible({ timeout: 30000 });
@@ -447,7 +466,7 @@ test("confirming terminal close with running command removes the tab", async ({
const tabTestId = await getFirstTerminalTabTestId(page);
const terminalId = tabTestId.replace("terminal-tab-", "");
const tab = page.getByTestId(tabTestId).first();
const tab = visibleTestId(page, tabTestId);
await expect(tab).toBeVisible({ timeout: 30000 });
const runningMarker = `terminal-close-running-accept-${Date.now()}`;
@@ -464,7 +483,7 @@ test("confirming terminal close with running command removes the tab", async ({
await dialog.accept();
}
);
await page.getByTestId(`terminal-close-${terminalId}`).first().click();
await visibleTestId(page, `terminal-close-${terminalId}`).click();
await dialogPromise;
await expect(page.getByTestId(tabTestId)).toHaveCount(0, {
@@ -497,7 +516,7 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => {
await openAgentFromSidebar(page, first.serverId, first.agentId);
await openTerminalsPanel(page);
await page.getByTestId("terminals-create-button").first().click();
await visibleTestId(page, "terminals-create-button").click();
await selectNewestTerminalTab(page);
await openAgentFromSidebar(page, second.serverId, second.agentId);
@@ -510,7 +529,7 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => {
await openAgentFromSidebar(page, first.serverId, first.agentId);
await openTerminalsPanel(page);
await selectNewestTerminalTab(page);
await expect(page.getByTestId("terminal-surface").first()).toContainText(sharedMarker, {
await expect(visibleTestId(page, "terminal-surface")).toContainText(sharedMarker, {
timeout: 30000,
});
} finally {
@@ -529,7 +548,7 @@ test("terminal captures escape and ctrl+c key input", async ({ page }) => {
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
@@ -546,7 +565,9 @@ test("terminal captures escape and ctrl+c key input", async ({ page }) => {
await page.keyboard.press("Control+B");
await expect(surface).toContainText("^B", { timeout: 30000 });
const marker = `terminal-key-capture-${Date.now()}`;
// Clear any line-editor residue before validating the next shell command.
await page.keyboard.press("Enter");
const marker = shortTerminalMarker("key-capture");
await page.keyboard.type(`echo ${marker}`, { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(marker, { timeout: 30000 });
@@ -565,7 +586,7 @@ test("Cmd+B toggles sidebar even when terminal is focused", async ({ page }) =>
await createAgent(page, "Terminal Cmd+B");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
@@ -594,17 +615,28 @@ async function getTerminalRows(page: Page): Promise<number> {
});
}
async function setExplorerContentBottomPadding(page: Page, padding: number): Promise<void> {
async function setTerminalHeightInset(page: Page, inset: number): Promise<void> {
await page.evaluate((nextPadding) => {
const container = document.querySelector<HTMLElement>(
'[data-testid="explorer-content-area"]'
const surfaces = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="terminal-surface"]')
);
if (!container) {
const activeSurface =
surfaces.find((surface) => surface.offsetParent !== null) ?? surfaces[0] ?? null;
if (!activeSurface) {
return;
}
container.style.boxSizing = "border-box";
container.style.paddingBottom = nextPadding + "px";
}, padding);
const host = activeSurface.parentElement as HTMLElement | null;
if (!host) {
return;
}
if (nextPadding > 0) {
host.style.flex = "0 0 auto";
host.style.height = `calc(100% - ${nextPadding}px)`;
return;
}
host.style.flex = "1 1 auto";
host.style.height = "100%";
}, inset);
}
async function getTerminalScrollbackDistance(page: Page): Promise<number> {
@@ -646,12 +678,12 @@ test("terminal viewport resizes and uses xterm scrollback", async ({ page }) =>
.toBeGreaterThan(0);
const initialRows = await getTerminalRows(page);
await setExplorerContentBottomPadding(page, 220);
await setTerminalHeightInset(page, 220);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeLessThan(initialRows);
await setExplorerContentBottomPadding(page, 0);
await setTerminalHeightInset(page, 0);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThanOrEqual(initialRows);
@@ -679,7 +711,7 @@ test("terminal viewport resizes and uses xterm scrollback", async ({ page }) =>
`${scrollbackMarker}-180`
);
const surface = page.getByTestId("terminal-surface").first();
const surface = visibleTestId(page, "terminal-surface");
await surface.hover();
await page.mouse.wheel(0, -3000);
+4 -1
View File
@@ -11,7 +11,10 @@ export default defineConfig({
expect: {
timeout: 10_000,
},
fullyParallel: true,
// E2E tests share a single daemon/relay/metro stack from global setup.
// Running tests concurrently causes cross-test contention and non-deterministic failures.
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: [['list']],
use: {
+1
View File
@@ -464,6 +464,7 @@ export default function RootLayout() {
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
+14 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react";
import { ActivityIndicator, View } from "react-native";
import { useRouter } from "expo-router";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
@@ -9,14 +9,26 @@ import { buildHostAgentDraftRoute } from "@/utils/host-routes";
export default function Index() {
const router = useRouter();
const params = useLocalSearchParams<{ serverId?: string }>();
const { theme } = useUnistyles();
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const requestedServerId = useMemo(() => {
return typeof params.serverId === "string" ? params.serverId.trim() : "";
}, [params.serverId]);
const targetServerId = useMemo(() => {
if (daemons.length === 0) {
return null;
}
if (requestedServerId) {
const requested = daemons.find(
(daemon) => daemon.serverId === requestedServerId
);
if (requested) {
return requested.serverId;
}
}
if (preferences.serverId) {
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
if (match) {
@@ -24,7 +36,7 @@ export default function Index() {
}
}
return daemons[0]?.serverId ?? null;
}, [daemons, preferences.serverId]);
}, [daemons, preferences.serverId, requestedServerId]);
useEffect(() => {
if (registryLoading || preferencesLoading) {
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useMemo } from "react";
import { ActivityIndicator, View } from "react-native";
import { useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostSettingsRoute } from "@/utils/host-routes";
export default function LegacySettingsRoute() {
const router = useRouter();
const { theme } = useUnistyles();
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const targetServerId = useMemo(() => {
if (daemons.length === 0) {
return null;
}
if (preferences.serverId) {
const match = daemons.find(
(daemon) => daemon.serverId === preferences.serverId
);
if (match) {
return match.serverId;
}
}
return daemons[0]?.serverId ?? null;
}, [daemons, preferences.serverId]);
useEffect(() => {
if (registryLoading || preferencesLoading) {
return;
}
if (!targetServerId) {
return;
}
router.replace(buildHostSettingsRoute(targetServerId) as any);
}, [preferencesLoading, registryLoading, router, targetServerId]);
if (registryLoading || preferencesLoading) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
);
}
if (!targetServerId) {
return <DraftAgentScreen />;
}
return null;
}
@@ -324,6 +324,7 @@ interface ComboSelectProps {
onSelect: (id: string) => void;
icon?: ReactElement;
showLabel?: boolean;
testID?: string;
}
export function ComboSelect({
@@ -338,6 +339,7 @@ export function ComboSelect({
onSelect,
icon,
showLabel = true,
testID,
}: ComboSelectProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
@@ -361,6 +363,7 @@ export function ComboSelect({
controlRef={anchorRef}
icon={icon}
showLabel={showLabel}
testID={testID}
/>
<Combobox
options={options}
@@ -573,6 +576,7 @@ export function AgentConfigRow({
onSelect={onSelectProvider}
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
testID="draft-provider-select"
/>
</View>
<View style={styles.agentConfigColumn}>
@@ -587,6 +591,7 @@ export function AgentConfigRow({
onSelect={onSelectModel}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
testID="draft-model-select"
/>
</View>
<View style={styles.agentConfigColumn}>
@@ -600,6 +605,7 @@ export function AgentConfigRow({
onSelect={onSelectMode}
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
testID="draft-mode-select"
/>
</View>
{thinkingSelectOptions.length > 0 ? (
@@ -36,6 +36,7 @@ import {
persistAttachmentFromFileUri,
} from '@/attachments/service'
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
type QueuedMessage = {
id: string
@@ -77,6 +78,7 @@ export function AgentInputArea({
onAddImages,
commandDraftConfig,
}: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const { height: keyboardHeight } = useReanimatedKeyboardAnimation()
@@ -41,9 +41,10 @@ describe("resolveStreamRenderStrategy", () => {
isMobileBreakpoint: false,
});
expect(strategy.kind).toBe("forward_stream");
expect(strategy.flatListInverted).toBe(false);
expect(strategy.overlayScrollbarInverted).toBe(false);
expect(strategy.shouldUseVirtualizedList()).toBe(false);
expect(strategy.getFlatListInverted()).toBe(false);
expect(strategy.getOverlayScrollbarInverted()).toBe(false);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
});
it("uses inverted_stream on native", () => {
@@ -52,9 +53,10 @@ describe("resolveStreamRenderStrategy", () => {
isMobileBreakpoint: false,
});
expect(strategy.kind).toBe("inverted_stream");
expect(strategy.flatListInverted).toBe(true);
expect(strategy.overlayScrollbarInverted).toBe(true);
expect(strategy.shouldUseVirtualizedList()).toBe(true);
expect(strategy.getFlatListInverted()).toBe(true);
expect(strategy.getOverlayScrollbarInverted()).toBe(true);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false);
});
});
@@ -1,5 +1,5 @@
import type { ComponentType, ReactElement } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { ComponentType, ReactElement, RefObject } from "react";
import type { FlatList, ScrollView, StyleProp, View, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
type EdgeSlot = "header" | "footer";
@@ -11,44 +11,6 @@ export type MaintainVisibleContentPositionConfig = Readonly<{
autoscrollToTopThreshold: number;
}>;
type StreamRenderStrategyBase = {
kind: "inverted_stream" | "forward_stream";
flatListInverted: boolean;
edgeSlot: EdgeSlot;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
assistantTurnTraversalStep: AssistantTurnTraversalStep;
disableParentScrollOnInlineDetailsExpansion: boolean;
};
export type InvertedStreamRenderStrategy = StreamRenderStrategyBase & {
kind: "inverted_stream";
flatListInverted: true;
edgeSlot: "header";
overlayScrollbarInverted: true;
maintainVisibleContentPosition: MaintainVisibleContentPositionConfig;
assistantTurnTraversalStep: 1;
disableParentScrollOnInlineDetailsExpansion: false;
};
export type ForwardStreamRenderStrategy = StreamRenderStrategyBase & {
kind: "forward_stream";
flatListInverted: false;
edgeSlot: "footer";
overlayScrollbarInverted: false;
maintainVisibleContentPosition?: undefined;
assistantTurnTraversalStep: -1;
};
export type StreamRenderStrategy =
| InvertedStreamRenderStrategy
| ForwardStreamRenderStrategy;
export type ResolveStreamRenderStrategyInput = {
platform: string;
isMobileBreakpoint: boolean;
};
export type StreamViewportMetrics = {
contentHeight: number;
viewportHeight: number;
@@ -66,104 +28,143 @@ export type StreamEdgeSlotProps = {
ListFooterComponentStyle?: StyleProp<ViewStyle>;
};
export type StreamRenderRefs = {
flatListRef: RefObject<FlatList<StreamItem> | null>;
scrollViewRef: RefObject<ScrollView | null>;
bottomAnchorRef: RefObject<View | null>;
};
export type ResolveStreamRenderStrategyInput = {
platform: string;
isMobileBreakpoint: boolean;
};
export interface StreamRenderStrategy {
orderTail: (streamItems: StreamItem[]) => StreamItem[];
orderHead: (streamHead: StreamItem[]) => StreamItem[];
getNeighborIndex: (index: number, relation: NeighborRelation) => number;
getNeighborItem: (
items: StreamItem[],
index: number,
relation: NeighborRelation
) => StreamItem | undefined;
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
getEdgeSlotProps: (
component: ReactElement | ComponentType<any> | null,
gapSize: number
) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () =>
| MaintainVisibleContentPositionConfig
| undefined;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
shouldAnchorBottomOnContentSizeChange: () => boolean;
shouldAnimateManualScrollToBottom: () => boolean;
shouldUseVirtualizedList: () => boolean;
scrollToBottom: (params: {
refs: StreamRenderRefs;
metrics: StreamViewportMetrics;
animated: boolean;
}) => void;
scrollToOffset: (params: {
refs: StreamRenderRefs;
offset: number;
animated: boolean;
}) => void;
}
type StreamRenderStrategyConfig = {
orderTailReverse: boolean;
orderHeadReverse: boolean;
assistantTurnTraversalStep: AssistantTurnTraversalStep;
edgeSlot: EdgeSlot;
flatListInverted: boolean;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
disableParentScrollOnInlineDetailsExpansion: boolean;
anchorBottomOnContentSizeChange: boolean;
animateManualScrollToBottom: boolean;
useVirtualizedList: boolean;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
scrollToBottom: (params: {
refs: StreamRenderRefs;
metrics: StreamViewportMetrics;
animated: boolean;
}) => void;
scrollToOffset: (params: {
refs: StreamRenderRefs;
offset: number;
animated: boolean;
}) => void;
};
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION: MaintainVisibleContentPositionConfig =
Object.freeze({
minIndexForVisible: 0,
autoscrollToTopThreshold: 0,
});
const INVERTED_STREAM_STRATEGY: InvertedStreamRenderStrategy = {
kind: "inverted_stream",
flatListInverted: true,
edgeSlot: "header",
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
assistantTurnTraversalStep: 1,
disableParentScrollOnInlineDetailsExpansion: false,
};
function scrollAnchorIntoView(params: {
refs: StreamRenderRefs;
animated: boolean;
}): boolean {
const anchorHandle = params.refs.bottomAnchorRef.current as
| ({ getNativeRef?: () => unknown; scrollIntoView?: (options?: unknown) => void } &
object)
| null;
if (!anchorHandle) {
return false;
}
const FORWARD_STREAM_STRATEGY_DESKTOP: ForwardStreamRenderStrategy = {
kind: "forward_stream",
flatListInverted: false,
edgeSlot: "footer",
overlayScrollbarInverted: false,
assistantTurnTraversalStep: -1,
disableParentScrollOnInlineDetailsExpansion: true,
};
const maybeNative =
typeof anchorHandle.getNativeRef === "function"
? anchorHandle.getNativeRef()
: anchorHandle;
const FORWARD_STREAM_STRATEGY_MOBILE: ForwardStreamRenderStrategy = {
...FORWARD_STREAM_STRATEGY_DESKTOP,
disableParentScrollOnInlineDetailsExpansion: false,
};
const domElement = maybeNative as { scrollIntoView?: (options?: unknown) => void };
if (typeof domElement.scrollIntoView !== "function") {
return false;
}
export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput
domElement.scrollIntoView({
block: "end",
behavior: params.animated ? "smooth" : "auto",
});
return true;
}
function createStreamRenderStrategy(
config: StreamRenderStrategyConfig
): StreamRenderStrategy {
if (input.platform === "web") {
return input.isMobileBreakpoint
? FORWARD_STREAM_STRATEGY_MOBILE
: FORWARD_STREAM_STRATEGY_DESKTOP;
}
return INVERTED_STREAM_STRATEGY;
}
export function orderTailForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
streamItems: StreamItem[];
}): StreamItem[] {
const { strategy, streamItems } = params;
return strategy.kind === "inverted_stream"
? [...streamItems].reverse()
: streamItems;
}
export function orderHeadForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
streamHead: StreamItem[];
}): StreamItem[] {
const { strategy, streamHead } = params;
return strategy.kind === "inverted_stream"
? [...streamHead].reverse()
: streamHead;
}
export function getStreamNeighborIndex(params: {
strategy: StreamRenderStrategy;
index: number;
relation: NeighborRelation;
}): number {
const { strategy, index, relation } = params;
if (strategy.kind === "inverted_stream") {
return relation === "above" ? index + 1 : index - 1;
}
return relation === "above" ? index - 1 : index + 1;
}
export function getStreamNeighborItem(params: {
strategy: StreamRenderStrategy;
items: StreamItem[];
index: number;
relation: NeighborRelation;
}): StreamItem | undefined {
const nextIndex = getStreamNeighborIndex(params);
if (nextIndex < 0 || nextIndex >= params.items.length) {
return {
orderTail: (streamItems) =>
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
orderHead: (streamHead) =>
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
getNeighborIndex: (index, relation) =>
relation === "above"
? index + config.assistantTurnTraversalStep
: index - config.assistantTurnTraversalStep,
getNeighborItem: (items, index, relation) => {
const neighborIndex =
relation === "above"
? index + config.assistantTurnTraversalStep
: index - config.assistantTurnTraversalStep;
if (neighborIndex < 0 || neighborIndex >= items.length) {
return undefined;
}
return params.items[nextIndex];
}
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
items: StreamItem[];
startIndex: number;
}): string {
const { strategy, items, startIndex } = params;
return items[neighborIndex];
},
collectAssistantTurnContent: (items, startIndex) => {
const messages: string[] = [];
for (
let index = startIndex;
index >= 0 && index < items.length;
index += strategy.assistantTurnTraversalStep
index += config.assistantTurnTraversalStep
) {
const currentItem = items[index];
if (currentItem.kind === "user_message") {
@@ -173,42 +174,12 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
messages.push(currentItem.text);
}
}
return messages.reverse().join("\n\n");
}
export function isNearBottomForStreamRenderStrategy(
params: StreamNearBottomInput & { strategy: StreamRenderStrategy }
): boolean {
const { strategy, threshold, offsetY } = params;
if (strategy.kind === "inverted_stream") {
return offsetY <= threshold;
}
const distanceFromBottom = Math.max(
0,
params.contentHeight - (offsetY + params.viewportHeight)
);
return distanceFromBottom <= threshold;
}
export function getBottomOffsetForStreamRenderStrategy(params: StreamViewportMetrics & {
strategy: StreamRenderStrategy;
}): number {
const { strategy } = params;
if (strategy.kind === "inverted_stream") {
return 0;
}
return Math.max(0, params.contentHeight - params.viewportHeight);
}
export function getStreamEdgeSlotProps(params: {
strategy: StreamRenderStrategy;
component: ReactElement | ComponentType<any> | null;
gapSize: number;
}): StreamEdgeSlotProps {
const { strategy, component, gapSize } = params;
if (strategy.edgeSlot === "header") {
},
isNearBottom: (input) => config.isNearBottom(input),
getBottomOffset: (metrics) => config.getBottomOffset(metrics),
getEdgeSlotProps: (component, gapSize) => {
if (config.edgeSlot === "header") {
return {
ListHeaderComponent: component,
ListHeaderComponentStyle: { marginBottom: gapSize },
@@ -218,4 +189,168 @@ export function getStreamEdgeSlotProps(params: {
ListFooterComponent: component,
ListFooterComponentStyle: { marginTop: gapSize },
};
},
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
getFlatListInverted: () => config.flatListInverted,
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
config.disableParentScrollOnInlineDetailsExpansion,
shouldAnchorBottomOnContentSizeChange: () =>
config.anchorBottomOnContentSizeChange,
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
shouldUseVirtualizedList: () => config.useVirtualizedList,
scrollToBottom: (params) => config.scrollToBottom(params),
scrollToOffset: (params) => config.scrollToOffset(params),
};
}
function createInvertedStreamStrategy(): StreamRenderStrategy {
return createStreamRenderStrategy({
orderTailReverse: true,
orderHeadReverse: true,
assistantTurnTraversalStep: 1,
edgeSlot: "header",
flatListInverted: true,
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: false,
animateManualScrollToBottom: true,
useVirtualizedList: true,
isNearBottom: (input) => input.offsetY <= input.threshold,
getBottomOffset: () => 0,
scrollToBottom: ({ refs, animated }) => {
refs.flatListRef.current?.scrollToOffset({
offset: 0,
animated,
});
},
scrollToOffset: ({ refs, offset, animated }) => {
refs.flatListRef.current?.scrollToOffset({ offset, animated });
},
});
}
function createForwardStreamStrategy(): StreamRenderStrategy {
return createStreamRenderStrategy({
orderTailReverse: false,
orderHeadReverse: false,
assistantTurnTraversalStep: -1,
edgeSlot: "footer",
flatListInverted: false,
overlayScrollbarInverted: false,
maintainVisibleContentPosition: undefined,
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: true,
animateManualScrollToBottom: false,
useVirtualizedList: false,
isNearBottom: (inputMetrics) => {
const distanceFromBottom = Math.max(
0,
inputMetrics.contentHeight -
(inputMetrics.offsetY + inputMetrics.viewportHeight)
);
return distanceFromBottom <= inputMetrics.threshold;
},
getBottomOffset: (metrics) =>
Math.max(0, metrics.contentHeight - metrics.viewportHeight),
scrollToBottom: ({ refs, metrics, animated }) => {
if (scrollAnchorIntoView({ refs, animated })) {
return;
}
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
refs.scrollViewRef.current?.scrollTo?.({
y: Math.max(0, metrics.contentHeight - metrics.viewportHeight),
animated,
});
},
scrollToOffset: ({ refs, offset, animated }) => {
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
},
});
}
export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput
): StreamRenderStrategy {
if (input.platform === "web") {
return createForwardStreamStrategy();
}
return createInvertedStreamStrategy();
}
export function orderTailForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
streamItems: StreamItem[];
}): StreamItem[] {
return params.strategy.orderTail(params.streamItems);
}
export function orderHeadForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
streamHead: StreamItem[];
}): StreamItem[] {
return params.strategy.orderHead(params.streamHead);
}
export function getStreamNeighborIndex(params: {
strategy: StreamRenderStrategy;
index: number;
relation: NeighborRelation;
}): number {
return params.strategy.getNeighborIndex(params.index, params.relation);
}
export function getStreamNeighborItem(params: {
strategy: StreamRenderStrategy;
items: StreamItem[];
index: number;
relation: NeighborRelation;
}): StreamItem | undefined {
return params.strategy.getNeighborItem(
params.items,
params.index,
params.relation
);
}
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
items: StreamItem[];
startIndex: number;
}): string {
return params.strategy.collectAssistantTurnContent(
params.items,
params.startIndex
);
}
export function isNearBottomForStreamRenderStrategy(
params: StreamNearBottomInput & { strategy: StreamRenderStrategy }
): boolean {
return params.strategy.isNearBottom({
offsetY: params.offsetY,
threshold: params.threshold,
contentHeight: params.contentHeight,
viewportHeight: params.viewportHeight,
});
}
export function getBottomOffsetForStreamRenderStrategy(
params: StreamViewportMetrics & {
strategy: StreamRenderStrategy;
}
): number {
return params.strategy.getBottomOffset({
contentHeight: params.contentHeight,
viewportHeight: params.viewportHeight,
});
}
export function getStreamEdgeSlotProps(params: {
strategy: StreamRenderStrategy;
component: ReactElement | ComponentType<any> | null;
gapSize: number;
}): StreamEdgeSlotProps {
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
}
+176 -38
View File
@@ -1,10 +1,20 @@
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import type { ReactNode } from "react";
import {
Fragment,
createElement,
isValidElement,
useEffect,
useMemo,
useRef,
useState,
useCallback,
} from "react";
import type { ComponentType, ReactElement, ReactNode } from "react";
import {
View,
Text,
Pressable,
FlatList,
ScrollView,
ListRenderItemInfo,
LayoutChangeEvent,
NativeScrollEvent,
@@ -57,13 +67,13 @@ import {
} from "./web-desktop-scrollbar";
import {
collectAssistantTurnContentForStreamRenderStrategy,
getBottomOffsetForStreamRenderStrategy,
getStreamEdgeSlotProps,
getStreamNeighborItem,
isNearBottomForStreamRenderStrategy,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveStreamRenderStrategy,
type StreamEdgeSlotProps,
} from "./agent-stream-render-strategy";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
@@ -76,6 +86,24 @@ const isToolSequenceItem = (item?: StreamItem) =>
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
const STREAM_ITEM_LOG_MIN_COUNT = 200;
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
const NOOP_SEPARATORS: ListRenderItemInfo<StreamItem>["separators"] = {
highlight: () => {},
unhighlight: () => {},
updateProps: () => {},
};
function renderStreamEdgeComponent(
component: ReactElement | ComponentType<any> | null | undefined
): ReactNode {
if (!component) {
return null;
}
if (isValidElement(component)) {
return component;
}
return createElement(component);
}
export interface AgentStreamViewProps {
agentId: string;
serverId?: string;
@@ -92,6 +120,8 @@ export function AgentStreamView({
pendingPermissions,
}: AgentStreamViewProps) {
const flatListRef = useRef<FlatList<StreamItem>>(null);
const scrollViewRef = useRef<ScrollView>(null);
const bottomAnchorRef = useRef<View>(null);
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -111,6 +141,7 @@ export function AgentStreamView({
const isNearBottomRef = useRef(true);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollAnimatedRef = useRef(false);
const scrollOffsetYRef = useRef(0);
const streamItemCountRef = useRef(0);
const streamViewportMetricsRef = useRef({
contentHeight: 0,
@@ -120,6 +151,10 @@ export function AgentStreamView({
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const streamRenderRefs = useMemo(
() => ({ flatListRef, scrollViewRef, bottomAnchorRef }),
[]
);
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? agent.serverId ?? "";
@@ -192,6 +227,7 @@ export function AgentStreamView({
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
scrollOffsetYRef.current = contentOffset.y;
streamViewportMetricsRef.current = {
contentHeight: Math.max(0, contentSize.height),
viewportHeight: Math.max(0, layoutMeasurement.height),
@@ -235,36 +271,61 @@ export function AgentStreamView({
[showDesktopWebScrollbar, streamScrollbarMetrics]
);
const scrollToBottomInternal = useCallback(
({ animated }: { animated: boolean }) => {
const targetOffset = streamRenderStrategy.getBottomOffset(
streamViewportMetricsRef.current
);
streamRenderStrategy.scrollToBottom({
refs: streamRenderRefs,
metrics: streamViewportMetricsRef.current,
animated,
});
scrollOffsetYRef.current = targetOffset;
isNearBottomRef.current = true;
setIsNearBottom(true);
},
[streamRenderRefs, streamRenderStrategy]
);
const handleContentSizeChange = useCallback(
(width: number, height: number) => {
const previousMetrics = streamViewportMetricsRef.current;
const threshold = Math.max(insets.bottom, 32);
const wasNearBottom = isNearBottomForStreamRenderStrategy({
strategy: streamRenderStrategy,
offsetY: scrollOffsetYRef.current,
threshold,
contentHeight: previousMetrics.contentHeight,
viewportHeight: previousMetrics.viewportHeight,
});
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
...previousMetrics,
contentHeight: Math.max(0, height),
};
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
if (!hasAutoScrolledOnce.current) {
scrollToBottomInternal({ animated: false });
hasAutoScrolledOnce.current = true;
hasScrolledInitially.current = true;
} else if (wasNearBottom || isNearBottomRef.current) {
scrollToBottomInternal({ animated: false });
}
}
if (showDesktopWebScrollbar) {
streamScrollbarMetrics.onContentSizeChange(width, height);
}
},
[showDesktopWebScrollbar, streamScrollbarMetrics]
);
const scrollToBottomInternal = useCallback(
({ animated }: { animated: boolean }) => {
const list = flatListRef.current;
if (!list) {
return;
}
const offset = getBottomOffsetForStreamRenderStrategy({
strategy: streamRenderStrategy,
contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
});
list.scrollToOffset({ offset, animated });
isNearBottomRef.current = true;
setIsNearBottom(true);
},
[streamRenderStrategy]
[
insets.bottom,
scrollToBottomInternal,
showDesktopWebScrollbar,
streamRenderStrategy,
streamScrollbarMetrics,
]
);
const scheduleAutoScroll = useCallback(
@@ -301,6 +362,11 @@ export function AgentStreamView({
return;
}
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
// Forward streams anchor from measurement updates in handleContentSizeChange.
return;
}
if (!hasAutoScrolledOnce.current) {
const handle = InteractionManager.runAfterInteractions(() => {
scrollToBottomInternal({ animated: false });
@@ -317,10 +383,16 @@ export function AgentStreamView({
const shouldAnimate = hasScrolledInitially.current;
scheduleAutoScroll({ animated: shouldAnimate });
hasScrolledInitially.current = true;
}, [streamItems, scheduleAutoScroll]);
}, [
scheduleAutoScroll,
scrollToBottomInternal,
streamItems,
streamRenderStrategy,
]);
function scrollToBottom() {
scrollToBottomInternal({ animated: true });
const animated = streamRenderStrategy.shouldAnimateManualScrollToBottom();
scrollToBottomInternal({ animated });
isNearBottomRef.current = true;
setIsNearBottom(true);
}
@@ -396,7 +468,7 @@ export function AgentStreamView({
(item: StreamItem, index: number, items: StreamItem[]) => {
const handleInlineDetailsExpandedChange = (expanded: boolean) => {
if (
!streamRenderStrategy.disableParentScrollOnInlineDetailsExpansion
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()
) {
return;
}
@@ -660,6 +732,7 @@ export function AgentStreamView({
const showWorkingIndicator = agent.status === "running";
const showBottomBar = showWorkingIndicator;
const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList();
const listEdgeSlotComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
@@ -718,8 +791,8 @@ export function AgentStreamView({
client,
orderedStreamHead,
renderStreamItemContent,
tightGap,
showBottomBar,
tightGap,
]);
const flatListExtraData = useMemo(
@@ -735,7 +808,7 @@ export function AgentStreamView({
]
);
const listEdgeSlotProps = useMemo(() => {
const listEdgeSlotProps = useMemo<StreamEdgeSlotProps>(() => {
if (!listEdgeSlotComponent) {
return {};
}
@@ -781,10 +854,45 @@ export function AgentStreamView({
theme.colors.foregroundMuted,
]);
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
const listContentContainerStyle = useMemo(
() =>
usesVirtualizedList
? stylesheet.listContentContainer
: [stylesheet.listContentContainer, stylesheet.forwardListContentContainer],
[usesVirtualizedList]
);
const headerEdgeContent = renderStreamEdgeComponent(
listEdgeSlotProps.ListHeaderComponent
);
const footerEdgeContent = renderStreamEdgeComponent(
listEdgeSlotProps.ListFooterComponent
);
const nonVirtualizedItems = useMemo(() => {
if (flatListData.length === 0) {
return null;
}
return flatListData.map((item, index) => {
const rendered = renderStreamItem({
item,
index,
separators: NOOP_SEPARATORS,
});
if (!rendered) {
return null;
}
return <Fragment key={item.id}>{rendered}</Fragment>;
});
}, [flatListData, renderStreamItem]);
return (
<ToolCallSheetProvider>
<View style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
{usesVirtualizedList ? (
<FlatList
ref={flatListRef}
data={flatListData}
@@ -792,7 +900,7 @@ export function AgentStreamView({
keyExtractor={(item) => item.id}
testID="agent-chat-scroll"
{...listEdgeSlotProps}
contentContainerStyle={stylesheet.listContentContainer}
contentContainerStyle={listContentContainerStyle}
style={stylesheet.list}
onLayout={handleListLayout}
onScroll={handleScroll}
@@ -801,24 +909,50 @@ export function AgentStreamView({
ListEmptyComponent={listEmptyComponent}
extraData={flatListExtraData}
maintainVisibleContentPosition={
streamRenderStrategy.maintainVisibleContentPosition
streamRenderStrategy.getMaintainVisibleContentPosition()
}
initialNumToRender={12}
windowSize={10}
scrollEnabled={
!streamRenderStrategy.disableParentScrollOnInlineDetailsExpansion ||
expandedInlineToolCallIds.size === 0
}
scrollEnabled={streamScrollEnabled}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
inverted={streamRenderStrategy.flatListInverted}
inverted={streamRenderStrategy.getFlatListInverted()}
/>
) : (
<ScrollView
ref={scrollViewRef}
testID="agent-chat-scroll"
contentContainerStyle={listContentContainerStyle}
style={stylesheet.list}
onLayout={handleListLayout}
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
scrollEnabled={streamScrollEnabled}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
{headerEdgeContent ? (
<View style={listEdgeSlotProps.ListHeaderComponentStyle}>
{headerEdgeContent}
</View>
) : null}
{nonVirtualizedItems}
{flatListData.length === 0 ? listEmptyComponent : null}
{footerEdgeContent ? (
<View style={listEdgeSlotProps.ListFooterComponentStyle}>
{footerEdgeContent}
</View>
) : null}
<View ref={bottomAnchorRef} collapsable={false} />
</ScrollView>
)}
</MessageOuterSpacingProvider>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={streamScrollbarMetrics}
inverted={streamRenderStrategy.overlayScrollbarInverted}
inverted={streamRenderStrategy.getOverlayScrollbarInverted()}
onScrollToOffset={(nextOffset) => {
flatListRef.current?.scrollToOffset({
streamRenderStrategy.scrollToOffset({
refs: streamRenderRefs,
offset: nextOffset,
animated: false,
});
@@ -1380,6 +1514,10 @@ const stylesheet = StyleSheet.create((theme) => ({
md: theme.spacing[4],
},
},
forwardListContentContainer: {
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
list: {
flex: 1,
},
@@ -162,6 +162,8 @@ export function DictationOverlay({
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
accessibilityRole="button"
accessibilityLabel="Cancel dictation"
style={[
overlayStyles.cancelButton,
actionsDisabled && !isFailed && overlayStyles.buttonDisabled,
@@ -213,6 +215,8 @@ export function DictationOverlay({
) : isFailed ? (
<Pressable
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Retry dictation"
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
@@ -228,6 +232,8 @@ export function DictationOverlay({
<>
<Pressable
onPress={onAccept}
accessibilityRole="button"
accessibilityLabel="Insert transcription"
style={[
overlayStyles.actionButton,
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
@@ -241,6 +247,8 @@ export function DictationOverlay({
</Pressable>
<Pressable
onPress={onAcceptAndSend}
accessibilityRole="button"
accessibilityLabel="Insert transcription and send"
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -52,6 +53,7 @@ interface ExplorerSidebarProps {
export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) {
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -267,6 +269,12 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
// On web, keep it interactive only while open so closed sidebars don't eat taps.
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
// screens so only the active screen exposes explorer/terminal surfaces.
if (!isScreenFocused) {
return null;
}
if (isMobile) {
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
+27 -5
View File
@@ -33,6 +33,10 @@ import { useAttachmentPreviewUrl } from '@/attachments/use-attachment-preview-ur
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Shortcut } from '@/components/ui/shortcut'
import type { MessageInputKeyboardActionKind } from '@/keyboard/actions'
import {
markScrollInvestigationEvent,
markScrollInvestigationRender,
} from '@/utils/scroll-jank-investigation'
export type ImageAttachment = AttachmentMetadata
@@ -148,6 +152,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
ref
) {
const { theme } = useUnistyles()
const investigationComponentId = `MessageInput:${voiceServerId ?? 'unknown-server'}:${voiceAgentId ?? 'unknown-agent'}`
markScrollInvestigationRender(investigationComponentId)
const toast = useToast()
const voice = useVoiceOptional()
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT)
@@ -247,7 +253,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (shouldAutoSend) {
const imageAttachments = images.length > 0 ? images : undefined
onSubmit({ text: nextValue, images: imageAttachments })
onSubmit({
text: nextValue,
images: imageAttachments,
forceSend: isAgentRunning || undefined,
})
} else {
onChangeText(nextValue)
}
@@ -258,7 +268,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
})
}
},
[onChangeText, onSubmit, images]
[onChangeText, onSubmit, images, isAgentRunning]
)
const handleDictationError = useCallback((error: Error) => {
@@ -302,8 +312,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onError: handleDictationError,
canStart: canStartDictation,
canConfirm: canConfirmDictation,
autoStopWhenHidden:
Platform.OS === 'web' ? undefined : { isVisible: isScreenFocused },
autoStopWhenHidden: { isVisible: isScreenFocused },
enableDuration: true,
})
@@ -330,9 +339,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const startDictationIfAvailable = useCallback(async () => {
if (dictationUnavailableMessage) {
isDictatingRef.current = false
toast.error(dictationUnavailableMessage)
return
}
// Keep hotkey toggling deterministic between the async start call and the
// state-ref sync effect, so a rapid second toggle routes to confirm.
isDictatingRef.current = true
await startDictation()
}, [dictationUnavailableMessage, startDictation, toast])
@@ -590,6 +603,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const shouldHandleDesktopSubmit = IS_WEB
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
markScrollInvestigationEvent(investigationComponentId, 'keyPress')
if (!shouldHandleDesktopSubmit) return
// Allow parent to intercept key events (e.g., for autocomplete navigation)
@@ -634,6 +648,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
? 'Send and interrupt'
: 'Send message'
const handleInputChange = useCallback(
(nextValue: string) => {
markScrollInvestigationEvent(investigationComponentId, 'inputChange')
onChangeText(nextValue)
},
[investigationComponentId, onChangeText]
)
return (
<View style={styles.container} testID="message-input-root">
{/* Regular input */}
@@ -672,7 +694,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<TextInput
ref={textInputRef}
value={value}
onChangeText={onChangeText}
onChangeText={handleInputChange}
placeholder={placeholder}
placeholderTextColor={theme.colors.mutedForeground}
onFocus={() => {
+7 -1
View File
@@ -72,6 +72,7 @@ import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
export type { InlinePathTarget } from "@/utils/inline-path";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
@@ -1293,6 +1294,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
const detailContent =
hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailWrapperRef = useRef<View | null>(null);
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${
testID ?? label
}`;
const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
@@ -1428,11 +1432,13 @@ const ExpandableBadge = memo(function ExpandableBadge({
}
};
markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelAttach");
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
return () => {
markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelDetach");
node.removeEventListener("wheel", stopWheelPropagation);
};
}, [isExpanded, hasDetailContent]);
}, [hasDetailContent, isExpanded, wheelInvestigationComponentId]);
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shimmerTranslateX.value }],
+75 -26
View File
@@ -17,6 +17,7 @@ import Svg, {
} from "react-native-svg";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
hasPendingTerminalModifiers,
@@ -557,29 +558,60 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}
}, []);
const flushPendingTerminalInput = useCallback(() => {
const dispatchTerminalInputEntry = useCallback(
(entry: PendingTerminalInput): boolean => {
if (!client) {
return;
return false;
}
const currentStreamId = getCurrentActiveStreamId();
if (currentStreamId === null) {
return;
const terminalId = selectedTerminalIdRef.current;
if (!terminalId) {
return false;
}
if (entry.type === "data") {
client.sendTerminalInput(terminalId, {
type: "input",
data: entry.data,
});
return true;
}
const encoded = encodeTerminalKeyInput(entry.input);
if (encoded.length === 0) {
return true;
}
client.sendTerminalInput(terminalId, {
type: "input",
data: encoded,
});
return true;
},
[client]
);
const flushPendingTerminalInput = useCallback(() => {
const queue = pendingTerminalInputRef.current;
if (queue.length === 0) {
return;
}
const pending = queue.splice(0, queue.length);
let sentCount = 0;
while (sentCount < queue.length) {
const entry = queue[sentCount];
if (!entry) {
break;
}
if (!dispatchTerminalInputEntry(entry)) {
break;
}
sentCount += 1;
}
for (const entry of pending) {
if (entry.type === "data") {
client.sendTerminalStreamInput(currentStreamId, entry.data);
continue;
if (sentCount > 0) {
queue.splice(0, sentCount);
}
client.sendTerminalStreamKey(currentStreamId, entry.input);
}
}, [client, getCurrentActiveStreamId]);
}, [dispatchTerminalInputEntry]);
useEffect(() => {
flushPendingTerminalInput();
@@ -625,8 +657,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
meta?: boolean;
}
): boolean => {
const currentStreamId = getCurrentActiveStreamId();
if (!client || currentStreamId === null) {
if (!client || !selectedTerminalIdRef.current) {
enqueuePendingTerminalInput({
type: "key",
input: {
@@ -641,6 +672,16 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}
const normalizedKey = normalizeTerminalTransportKey(input.key);
const pendingEntry: PendingTerminalInput = {
type: "key",
input: {
key: normalizedKey,
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
meta: input.meta,
},
};
terminalDebugLog({
scope: "terminal-pane",
event: "input:key:send",
@@ -649,19 +690,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
activeStreamId: currentStreamId,
activeStreamId: getCurrentActiveStreamId(),
},
});
client.sendTerminalStreamKey(currentStreamId, {
key: normalizedKey,
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
meta: input.meta,
});
if (!dispatchTerminalInputEntry(pendingEntry)) {
enqueuePendingTerminalInput(pendingEntry);
}
return true;
},
[client, enqueuePendingTerminalInput, getCurrentActiveStreamId]
[
client,
dispatchTerminalInputEntry,
enqueuePendingTerminalInput,
getCurrentActiveStreamId,
]
);
const handleTerminalData = useCallback(
@@ -705,7 +747,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}
}
if (!client || currentStreamId === null) {
if (!client || !selectedTerminalIdRef.current) {
enqueuePendingTerminalInput({
type: "data",
data,
@@ -721,11 +763,18 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
activeStreamId: currentStreamId,
},
});
client.sendTerminalStreamInput(currentStreamId, data);
const pendingEntry: PendingTerminalInput = {
type: "data",
data,
};
if (!dispatchTerminalInputEntry(pendingEntry)) {
enqueuePendingTerminalInput(pendingEntry);
}
},
[
clearPendingModifiers,
client,
dispatchTerminalInputEntry,
getCurrentActiveStreamId,
modifiers.alt,
modifiers.ctrl,
@@ -3,18 +3,45 @@ import { __private__ } from "./use-agent-initialization";
describe("useAgentInitialization timeline request policy", () => {
it("uses canonical tail bootstrap when cursor is missing", () => {
expect(__private__.buildInitialTimelineRequest(undefined)).toEqual({
expect(
__private__.buildInitialTimelineRequest({
cursor: undefined,
hasLocalTail: false,
initialTimelineLimit: 200,
})
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical catch-up after the current cursor when present", () => {
it("uses canonical tail bootstrap when cursor exists but local tail is empty", () => {
expect(
__private__.buildInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
endSeq: 42,
},
hasLocalTail: false,
initialTimelineLimit: 200,
})
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical catch-up after the current cursor when local tail exists", () => {
expect(
__private__.buildInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
endSeq: 42,
},
hasLocalTail: true,
initialTimelineLimit: 200,
})
).toEqual({
direction: "after",
@@ -23,4 +50,18 @@ describe("useAgentInitialization timeline request policy", () => {
projection: "canonical",
});
});
it("supports unbounded tail bootstrap policy", () => {
expect(
__private__.buildInitialTimelineRequest({
cursor: undefined,
hasLocalTail: false,
initialTimelineLimit: 0,
})
).toEqual({
direction: "tail",
limit: 0,
projection: "canonical",
});
});
});
@@ -1,4 +1,5 @@
import { useCallback } from "react";
import { Platform } from "react-native";
import { useSessionStore } from "@/stores/session-store";
import type {
DaemonClient,
@@ -13,20 +14,28 @@ import {
} from "@/utils/agent-initialization";
const INIT_TIMEOUT_MS = 5 * 60_000;
const DEFAULT_INITIAL_TIMELINE_LIMIT = 200;
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
const UNBOUNDED_TIMELINE_LIMIT = 0;
type TimelineCursorState = {
epoch: string;
endSeq: number;
};
type BuildInitialTimelineRequestInput = {
cursor: TimelineCursorState | undefined;
hasLocalTail: boolean;
initialTimelineLimit: number;
};
function buildInitialTimelineRequest(
cursor: TimelineCursorState | undefined
input: BuildInitialTimelineRequestInput
): FetchAgentTimelineOptions {
if (!cursor) {
const { cursor, hasLocalTail, initialTimelineLimit } = input;
if (!cursor || !hasLocalTail) {
return {
direction: "tail",
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
limit: initialTimelineLimit,
projection: "canonical",
};
}
@@ -40,8 +49,15 @@ function buildInitialTimelineRequest(
};
}
function resolveInitialTimelineLimit(): number {
return Platform.OS === "web"
? UNBOUNDED_TIMELINE_LIMIT
: NATIVE_INITIAL_TIMELINE_LIMIT;
}
export const __private__ = {
buildInitialTimelineRequest,
resolveInitialTimelineLimit,
};
export function useAgentInitialization({
@@ -76,7 +92,13 @@ export function useAgentInitialization({
const session = useSessionStore.getState().sessions[serverId];
const cursor = session?.agentTimelineCursor.get(agentId);
const timelineRequest = buildInitialTimelineRequest(cursor);
const hasLocalTail = (session?.agentStreamTail.get(agentId)?.length ?? 0) > 0;
const initialTimelineLimit = resolveInitialTimelineLimit();
const timelineRequest = buildInitialTimelineRequest({
cursor,
hasLocalTail,
initialTimelineLimit,
});
const initRequestDirection =
timelineRequest.direction === "after" ? "after" : "tail";
@@ -128,9 +150,10 @@ export function useAgentInitialization({
try {
await client.refreshAgent(agentId);
const initialTimelineLimit = resolveInitialTimelineLimit();
await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
limit: initialTimelineLimit,
projection: "canonical",
});
} catch (error) {
@@ -57,6 +57,92 @@ const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => {
return out;
};
const int16ToFloat32 = (input: Int16Array): Float32Array => {
const out = new Float32Array(input.length);
for (let i = 0; i < input.length; i += 1) {
out[i] = input[i]! / 32768;
}
return out;
};
type Pcm16Wav = {
sampleRate: number;
samples: Int16Array;
};
const parsePcm16Wav = (buffer: ArrayBuffer): Pcm16Wav | null => {
if (buffer.byteLength < 44) {
return null;
}
const view = new DataView(buffer);
const readAscii = (offset: number, length: number): string => {
let out = "";
for (let i = 0; i < length; i += 1) {
out += String.fromCharCode(view.getUint8(offset + i));
}
return out;
};
if (readAscii(0, 4) !== "RIFF" || readAscii(8, 4) !== "WAVE") {
return null;
}
let offset = 12;
let channels = 0;
let sampleRate = 0;
let bitsPerSample = 0;
let dataOffset = 0;
let dataSize = 0;
while (offset + 8 <= buffer.byteLength) {
const chunkId = readAscii(offset, 4);
const chunkSize = view.getUint32(offset + 4, true);
const chunkDataOffset = offset + 8;
if (chunkDataOffset + chunkSize > buffer.byteLength) {
break;
}
if (chunkId === "fmt " && chunkSize >= 16) {
const audioFormat = view.getUint16(chunkDataOffset, true);
channels = view.getUint16(chunkDataOffset + 2, true);
sampleRate = view.getUint32(chunkDataOffset + 4, true);
bitsPerSample = view.getUint16(chunkDataOffset + 14, true);
if (audioFormat !== 1) {
return null;
}
} else if (chunkId === "data") {
dataOffset = chunkDataOffset;
dataSize = chunkSize;
break;
}
offset = chunkDataOffset + chunkSize + (chunkSize % 2);
}
if (!dataOffset || !dataSize || sampleRate <= 0 || bitsPerSample !== 16 || channels <= 0) {
return null;
}
const sampleCount = Math.floor(dataSize / 2);
const interleaved = new Int16Array(buffer, dataOffset, sampleCount);
if (channels === 1) {
return { sampleRate, samples: new Int16Array(interleaved) };
}
const frameCount = Math.floor(interleaved.length / channels);
const mono = new Int16Array(frameCount);
for (let frame = 0; frame < frameCount; frame += 1) {
let sum = 0;
for (let ch = 0; ch < channels; ch += 1) {
sum += interleaved[frame * channels + ch] ?? 0;
}
mono[frame] = Math.round(sum / channels);
}
return { sampleRate, samples: mono };
};
const int16ToBase64 = (pcm: Int16Array): string => {
const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
let binary = "";
@@ -359,16 +445,23 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
}
}
if (mode === "recorder" && recorder.stoppedPromise && context) {
if (mode === "recorder" && recorder.stoppedPromise) {
try {
const blob = await recorder.stoppedPromise;
const arrayBuffer = await blob.arrayBuffer();
if (arrayBuffer.byteLength > 0) {
const parsedWav = parsePcm16Wav(arrayBuffer);
if (parsedWav) {
const floatPcm = int16ToFloat32(parsedWav.samples);
const pcm16 = resampleToPcm16(floatPcm, parsedWav.sampleRate, 16000);
emitPcmSegments(pcm16);
} else if (context) {
const decoded = await decodeAudioData(context, arrayBuffer);
const floatPcm = decoded.getChannelData(0);
const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000);
emitPcmSegments(pcm16);
}
}
} catch (err) {
onErrorRef.current?.(err instanceof Error ? err : new Error(String(err)));
}
+9 -5
View File
@@ -32,6 +32,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<DictationStatus>("idle");
const latestPartialTranscriptRef = useRef("");
const onTranscriptRef = useRef(onTranscript);
useEffect(() => {
@@ -130,6 +131,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
const clearStreamingState = useCallback(() => {
senderRef.current?.clearAll();
latestPartialTranscriptRef.current = "";
setPartialTranscript("");
}, []);
@@ -181,6 +183,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
return;
}
const next = message.payload.text ?? "";
latestPartialTranscriptRef.current = next;
setPartialTranscript(next);
onPartialTranscriptRef.current?.(next, { requestId: generateMessageId() });
});
@@ -203,13 +206,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
(text: string, requestId: string) => {
setIsProcessing(false);
isProcessingRef.current = false;
setPartialTranscript("");
setDuration(0);
setStatus("idle");
const transcriptText = (
text.trim().length > 0 ? text.trim() : latestPartialTranscriptRef.current.trim()
);
clearStreamingState();
const transcriptText = text.trim();
if (!transcriptText) {
return;
}
@@ -261,14 +265,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
try {
await audio.start();
if (client?.isConnected) {
await startNewStream("start");
}
isRecordingRef.current = true;
setIsRecording(true);
if (enableDuration) {
startDurationTracking();
}
if (client?.isConnected) {
await startNewStream("start");
}
} catch (err) {
await audio.stop().catch(() => undefined);
stopDurationTracking();
@@ -745,6 +745,7 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { labels: { ui: "true" } },
subscribe: { subscriptionId: "app:srv_test" },
page: { limit: 200 },
});
const snapshot = store.getSnapshot(host.serverId);
@@ -1088,8 +1088,9 @@ function AgentScreenContent({
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
<View style={styles.menuMetaContainer}>
<View style={styles.menuMetaContainer} testID="agent-details-sheet">
<Pressable
testID="agent-details-agent-id"
style={({ hovered, pressed }) => [
styles.menuMetaRow,
(hovered || pressed) && styles.menuMetaRowActive,
@@ -1102,6 +1103,7 @@ function AgentScreenContent({
Directory
</Text>
<Text
testID="agent-details-agent-id-value"
style={styles.menuMetaValue}
numberOfLines={1}
ellipsizeMode="middle"
@@ -1179,6 +1181,7 @@ function AgentScreenContent({
</Pressable>
<Pressable
testID="agent-details-persistence-session-id"
style={({ hovered, pressed }) => [
styles.menuMetaRow,
providerSessionId && (hovered || pressed) && styles.menuMetaRowActive,
@@ -1192,6 +1195,7 @@ function AgentScreenContent({
{providerLabel} ID
</Text>
<Text
testID="agent-details-persistence-session-id-value"
style={[styles.menuMetaValue, !providerSessionId && styles.menuMetaValueError]}
numberOfLines={1}
ellipsizeMode="middle"
@@ -1209,6 +1209,7 @@ function DraftAgentScreenContent({
icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false}
valueEllipsizeMode="middle"
testID="worktree-select-trigger"
/>
{worktreeMode === 'create' ? (
<FormSelectTrigger
@@ -1221,6 +1222,7 @@ function DraftAgentScreenContent({
disabled={repoInfoStatus === 'loading'}
icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false}
testID="worktree-base-branch-trigger"
/>
) : null}
</View>
@@ -15,6 +15,7 @@ interface KeyboardShortcutsState {
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[];
messageInputActionRequest: MessageInputActionRequest | null;
nextMessageInputActionRequestId: number;
setCommandCenterOpen: (open: boolean) => void;
setShortcutsDialogOpen: (open: boolean) => void;
@@ -38,6 +39,7 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [],
messageInputActionRequest: null,
nextMessageInputActionRequestId: 1,
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
@@ -47,9 +49,11 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
requestMessageInputAction: ({ agentKey, kind }) => {
const previous = get().messageInputActionRequest;
const id = (previous?.id ?? 0) + 1;
set({ messageInputActionRequest: { id, agentKey, kind } });
const id = get().nextMessageInputActionRequestId;
set({
messageInputActionRequest: { id, agentKey, kind },
nextMessageInputActionRequestId: id + 1,
});
},
clearMessageInputActionRequest: (id) => {
const current = get().messageInputActionRequest;
@@ -0,0 +1,582 @@
import { Platform } from "react-native";
type ListenerStats = {
adds: number;
removes: number;
active: number;
};
type TimerStats = {
created: number;
fired: number;
cleared: number;
active: number;
};
type WebSocketStats = {
created: number;
opened: number;
closed: number;
errored: number;
active: number;
};
type ComponentStats = {
mounts: number;
unmounts: number;
renders: number;
scrollEvents: number;
nearBottomTransitions: number;
metricUpdates: number;
itemRenderCalls: number;
wheelAttach: number;
wheelDetach: number;
inputChanges: number;
keyPresses: number;
lastRenderAtMs: number;
};
type ScrollInvestigationStore = {
markRender: (componentId: string) => void;
markEvent: (
componentId: string,
event:
| "mount"
| "unmount"
| "scrollEvent"
| "nearBottomTransition"
| "metricUpdate"
| "itemRenderCall"
| "wheelAttach"
| "wheelDetach"
| "inputChange"
| "keyPress"
) => void;
snapshot: () => {
listeners: {
byType: Record<string, ListenerStats>;
byCallsite: Record<string, number>;
activeUniqueKeys: number;
activeByTypeAndTarget: Record<string, Record<string, number>>;
};
timers: {
timeout: TimerStats;
interval: TimerStats;
raf: TimerStats;
};
websockets: {
totals: WebSocketStats;
activeByUrl: Record<string, number>;
};
components: Record<string, ComponentStats>;
};
printSnapshot: (label?: string) => void;
_installedAtMs: number;
};
type ScrollInvestigationGlobal = typeof globalThis & {
__PASEO_SCROLL_JANK_INVESTIGATION__?: ScrollInvestigationStore;
__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__?: boolean;
};
const TRACKED_EVENT_TYPES = new Set([
"wheel",
"scroll",
"pointermove",
"pointerup",
"pointercancel",
]);
const SOURCE_LABEL = "[ScrollJankInvestigation]";
function shouldInstall(): boolean {
const runtime = globalThis as ScrollInvestigationGlobal;
const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
return (
Platform.OS === "web" &&
isDev &&
!runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__
);
}
function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean {
if (typeof options === "boolean") {
return options;
}
return Boolean(options?.capture);
}
function describeEventTarget(target: EventTarget): string {
const element = target as Element;
if (element && typeof element === "object" && "tagName" in element) {
const tagName = (element.tagName || "unknown").toLowerCase();
const testId = element.getAttribute?.("data-testid");
const role = element.getAttribute?.("role");
const id = (element as HTMLElement).id || null;
const className = (element as HTMLElement).className;
const classLabel =
typeof className === "string" && className.trim().length > 0
? className.trim().split(/\s+/).slice(0, 2).join(".")
: null;
const connectivityLabel =
typeof (element as Node).isConnected === "boolean"
? (element as Node).isConnected
? "[connected]"
: "[detached]"
: null;
return [
tagName,
id ? `#${id}` : null,
testId ? `[data-testid=${testId}]` : null,
role ? `[role=${role}]` : null,
classLabel ? `.${classLabel}` : null,
connectivityLabel,
]
.filter(Boolean)
.join("");
}
const ctorName = (target as { constructor?: { name?: string } }).constructor
?.name;
return ctorName || "unknown-target";
}
function inferCallsite(): string {
const stack = new Error().stack;
if (!stack) {
return "unknown";
}
const frames = stack.split("\n");
for (const raw of frames.slice(2)) {
const line = raw.trim();
if (!line) {
continue;
}
if (line.includes("scroll-jank-investigation")) {
continue;
}
if (line.includes("patchedAddEventListener")) {
continue;
}
return line;
}
return "unknown";
}
function ensureListenerStats(
map: Map<string, ListenerStats>,
type: string
): ListenerStats {
const existing = map.get(type);
if (existing) {
return existing;
}
const next: ListenerStats = { adds: 0, removes: 0, active: 0 };
map.set(type, next);
return next;
}
function ensureComponentStats(
map: Map<string, ComponentStats>,
componentId: string
): ComponentStats {
const existing = map.get(componentId);
if (existing) {
return existing;
}
const next: ComponentStats = {
mounts: 0,
unmounts: 0,
renders: 0,
scrollEvents: 0,
nearBottomTransitions: 0,
metricUpdates: 0,
itemRenderCalls: 0,
wheelAttach: 0,
wheelDetach: 0,
inputChanges: 0,
keyPresses: 0,
lastRenderAtMs: 0,
};
map.set(componentId, next);
return next;
}
export function installScrollJankInvestigation(): void {
if (!shouldInstall()) {
return;
}
const runtime = globalThis as ScrollInvestigationGlobal;
if (runtime.__PASEO_SCROLL_JANK_INVESTIGATION__) {
return;
}
const targetIds = new WeakMap<object, number>();
const listenerIds = new WeakMap<object, number>();
const activeListenerKeys = new Set<string>();
const activeListenerMeta = new Map<
string,
{ type: string; target: string }
>();
const listenerStatsByType = new Map<string, ListenerStats>();
const listenerCallsiteCount = new Map<string, number>();
const componentStatsById = new Map<string, ComponentStats>();
const activeWsByUrl = new Map<string, number>();
const timeoutHandles = new Map<number, true>();
const intervalHandles = new Map<number, true>();
const rafHandles = new Map<number, true>();
let nextTargetId = 1;
let nextListenerId = 1;
const timerStats = {
timeout: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
interval: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
raf: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
};
const websocketStats: WebSocketStats = {
created: 0,
opened: 0,
closed: 0,
errored: 0,
active: 0,
};
const eventTargetProto = EventTarget.prototype as EventTarget & {
addEventListener: EventTarget["addEventListener"];
removeEventListener: EventTarget["removeEventListener"];
};
const nativeAddEventListener = eventTargetProto.addEventListener;
const nativeRemoveEventListener = eventTargetProto.removeEventListener;
function getTargetId(target: EventTarget): string {
const targetObj = target as unknown as object;
const existing = targetIds.get(targetObj);
if (existing) {
return String(existing);
}
const next = nextTargetId++;
targetIds.set(targetObj, next);
return String(next);
}
function getListenerId(listener: EventListenerOrEventListenerObject): string {
const listenerObj = listener as unknown as object;
const existing = listenerIds.get(listenerObj);
if (existing) {
return String(existing);
}
const next = nextListenerId++;
listenerIds.set(listenerObj, next);
return String(next);
}
function toListenerKey(
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean
): string {
return [
getTargetId(target),
type,
normalizeCapture(options) ? "capture" : "bubble",
getListenerId(listener),
].join("|");
}
eventTargetProto.addEventListener = function patchedAddEventListener(
this: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean
): void {
nativeAddEventListener.call(this, type, listener as any, options as any);
if (!listener) {
return;
}
const stats = ensureListenerStats(listenerStatsByType, type);
stats.adds += 1;
const key = toListenerKey(this, type, listener, options);
if (!activeListenerKeys.has(key)) {
activeListenerKeys.add(key);
stats.active += 1;
activeListenerMeta.set(key, {
type,
target: describeEventTarget(this),
});
}
if (TRACKED_EVENT_TYPES.has(type)) {
const callsite = inferCallsite();
const metricKey = `${type} :: ${callsite}`;
listenerCallsiteCount.set(
metricKey,
(listenerCallsiteCount.get(metricKey) ?? 0) + 1
);
}
};
eventTargetProto.removeEventListener = function patchedRemoveEventListener(
this: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean
): void {
nativeRemoveEventListener.call(this, type, listener as any, options as any);
if (!listener) {
return;
}
const stats = ensureListenerStats(listenerStatsByType, type);
stats.removes += 1;
const key = toListenerKey(this, type, listener, options);
if (activeListenerKeys.delete(key)) {
stats.active = Math.max(0, stats.active - 1);
activeListenerMeta.delete(key);
}
};
const nativeSetTimeout = globalThis.setTimeout.bind(globalThis);
const nativeClearTimeout = globalThis.clearTimeout.bind(globalThis);
const nativeSetInterval = globalThis.setInterval.bind(globalThis);
const nativeClearInterval = globalThis.clearInterval.bind(globalThis);
const nativeRequestAnimationFrame =
globalThis.requestAnimationFrame.bind(globalThis);
const nativeCancelAnimationFrame =
globalThis.cancelAnimationFrame.bind(globalThis);
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
timerStats.timeout.created += 1;
let timeoutId = -1;
const wrapped =
typeof handler === "function"
? (...handlerArgs: unknown[]) => {
if (timeoutHandles.delete(timeoutId)) {
timerStats.timeout.fired += 1;
timerStats.timeout.active = timeoutHandles.size;
}
return handler(...handlerArgs);
}
: handler;
timeoutId = nativeSetTimeout(wrapped, timeout, ...(args as any[])) as unknown as number;
timeoutHandles.set(timeoutId, true);
timerStats.timeout.active = timeoutHandles.size;
return timeoutId as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout;
globalThis.clearTimeout = ((timeoutId?: number) => {
if (typeof timeoutId === "number" && timeoutHandles.delete(timeoutId)) {
timerStats.timeout.cleared += 1;
timerStats.timeout.active = timeoutHandles.size;
}
return nativeClearTimeout(timeoutId);
}) as typeof clearTimeout;
globalThis.setInterval = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
timerStats.interval.created += 1;
let intervalId = -1;
const wrapped =
typeof handler === "function"
? (...handlerArgs: unknown[]) => {
timerStats.interval.fired += 1;
return handler(...handlerArgs);
}
: handler;
intervalId = nativeSetInterval(wrapped, timeout, ...(args as any[])) as unknown as number;
intervalHandles.set(intervalId, true);
timerStats.interval.active = intervalHandles.size;
return intervalId as unknown as ReturnType<typeof setInterval>;
}) as typeof setInterval;
globalThis.clearInterval = ((intervalId?: number) => {
if (typeof intervalId === "number" && intervalHandles.delete(intervalId)) {
timerStats.interval.cleared += 1;
timerStats.interval.active = intervalHandles.size;
}
return nativeClearInterval(intervalId);
}) as typeof clearInterval;
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
timerStats.raf.created += 1;
let rafId = -1;
const wrapped = (timestamp: number) => {
if (rafHandles.delete(rafId)) {
timerStats.raf.fired += 1;
timerStats.raf.active = rafHandles.size;
}
callback(timestamp);
};
rafId = nativeRequestAnimationFrame(wrapped) as unknown as number;
rafHandles.set(rafId, true);
timerStats.raf.active = rafHandles.size;
return rafId as unknown as ReturnType<typeof requestAnimationFrame>;
}) as typeof requestAnimationFrame;
globalThis.cancelAnimationFrame = ((rafId: number) => {
if (rafHandles.delete(rafId)) {
timerStats.raf.cleared += 1;
timerStats.raf.active = rafHandles.size;
}
return nativeCancelAnimationFrame(rafId);
}) as typeof cancelAnimationFrame;
const NativeWebSocket = globalThis.WebSocket;
if (typeof NativeWebSocket === "function") {
class InstrumentedWebSocket extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
if (protocols === undefined) {
super(url);
} else {
super(url, protocols);
}
const urlKey = String(url);
websocketStats.created += 1;
websocketStats.active += 1;
activeWsByUrl.set(urlKey, (activeWsByUrl.get(urlKey) ?? 0) + 1);
const handleOpen = () => {
websocketStats.opened += 1;
};
const handleError = () => {
websocketStats.errored += 1;
};
const handleClose = () => {
websocketStats.closed += 1;
websocketStats.active = Math.max(0, websocketStats.active - 1);
const current = activeWsByUrl.get(urlKey) ?? 0;
if (current <= 1) {
activeWsByUrl.delete(urlKey);
} else {
activeWsByUrl.set(urlKey, current - 1);
}
this.removeEventListener("open", handleOpen);
this.removeEventListener("error", handleError);
this.removeEventListener("close", handleClose);
};
this.addEventListener("open", handleOpen);
this.addEventListener("error", handleError);
this.addEventListener("close", handleClose);
}
}
globalThis.WebSocket = InstrumentedWebSocket as typeof WebSocket;
}
const store: ScrollInvestigationStore = {
markRender(componentId: string) {
const stats = ensureComponentStats(componentStatsById, componentId);
stats.renders += 1;
stats.lastRenderAtMs = performance.now();
},
markEvent(componentId: string, event) {
const stats = ensureComponentStats(componentStatsById, componentId);
switch (event) {
case "mount":
stats.mounts += 1;
return;
case "unmount":
stats.unmounts += 1;
return;
case "scrollEvent":
stats.scrollEvents += 1;
return;
case "nearBottomTransition":
stats.nearBottomTransitions += 1;
return;
case "metricUpdate":
stats.metricUpdates += 1;
return;
case "itemRenderCall":
stats.itemRenderCalls += 1;
return;
case "wheelAttach":
stats.wheelAttach += 1;
return;
case "wheelDetach":
stats.wheelDetach += 1;
return;
case "inputChange":
stats.inputChanges += 1;
return;
case "keyPress":
stats.keyPresses += 1;
return;
default:
return;
}
},
snapshot() {
const activeByTypeAndTarget: Record<string, Record<string, number>> = {};
for (const { type, target } of activeListenerMeta.values()) {
const existingByType = activeByTypeAndTarget[type] ?? {};
existingByType[target] = (existingByType[target] ?? 0) + 1;
activeByTypeAndTarget[type] = existingByType;
}
return {
listeners: {
byType: Object.fromEntries(listenerStatsByType.entries()),
byCallsite: Object.fromEntries(listenerCallsiteCount.entries()),
activeUniqueKeys: activeListenerKeys.size,
activeByTypeAndTarget,
},
timers: {
timeout: { ...timerStats.timeout, active: timeoutHandles.size },
interval: { ...timerStats.interval, active: intervalHandles.size },
raf: { ...timerStats.raf, active: rafHandles.size },
},
websockets: {
totals: { ...websocketStats },
activeByUrl: Object.fromEntries(activeWsByUrl.entries()),
},
components: Object.fromEntries(componentStatsById.entries()),
};
},
printSnapshot(label?: string) {
console.log(`${SOURCE_LABEL} ${label ?? "snapshot"}`, this.snapshot());
},
_installedAtMs: Date.now(),
};
runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ = store;
console.log(
`${SOURCE_LABEL} installed`,
"Use window.__PASEO_SCROLL_JANK_INVESTIGATION__.snapshot()"
);
}
function getStore(): ScrollInvestigationStore | null {
const runtime = globalThis as ScrollInvestigationGlobal;
return runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ ?? null;
}
export function markScrollInvestigationRender(componentId: string): void {
if (!shouldInstall()) {
return;
}
getStore()?.markRender(componentId);
}
export function markScrollInvestigationEvent(
componentId: string,
event:
| "mount"
| "unmount"
| "scrollEvent"
| "nearBottomTransition"
| "metricUpdate"
| "itemRenderCall"
| "wheelAttach"
| "wheelDetach"
| "inputChange"
| "keyPress"
): void {
if (!shouldInstall()) {
return;
}
getStore()?.markEvent(componentId, event);
}
+16
View File
@@ -0,0 +1,16 @@
type ScrollInvestigationEvent =
| "scrollEvent"
| "nearBottomTransition"
| "metricUpdate"
| "itemRenderCall"
| "wheelAttach"
| "wheelDetach";
export function installScrollJankInvestigation(): void {}
export function markScrollInvestigationRender(_componentId: string): void {}
export function markScrollInvestigationEvent(
_componentId: string,
_event: ScrollInvestigationEvent
): void {}
@@ -48,16 +48,25 @@ const daemonClientMock = vi.hoisted(() => {
};
});
const clientIdMock = vi.hoisted(() => ({
getOrCreateClientId: vi.fn(async () => "cid_shared_probe_test"),
}));
vi.mock("@server/client/daemon-client", () => ({
DaemonClient: daemonClientMock.MockDaemonClient,
}));
vi.mock("./client-id", () => ({
getOrCreateClientId: clientIdMock.getOrCreateClientId,
}));
describe("test-daemon-connection probe client identity", () => {
beforeEach(() => {
daemonClientMock.createdConfigs.length = 0;
clientIdMock.getOrCreateClientId.mockClear();
});
it("uses isolated probe clientId values for direct latency probes", async () => {
it("reuses the app clientId for direct latency probes", async () => {
const mod = await import("./test-daemon-connection");
await mod.measureConnectionLatency({
@@ -72,8 +81,8 @@ describe("test-daemon-connection probe client identity", () => {
});
const [first, second] = daemonClientMock.createdConfigs;
expect(first?.clientId).toMatch(/^cid_probe_/);
expect(second?.clientId).toMatch(/^cid_probe_/);
expect(first?.clientId).not.toBe(second?.clientId);
expect(first?.clientId).toBe("cid_shared_probe_test");
expect(second?.clientId).toBe("cid_shared_probe_test");
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
});
});
@@ -1,18 +1,9 @@
import { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { HostConnection } from "@/contexts/daemon-registry-context";
import { getOrCreateClientId } from "./client-id";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
function createProbeClientId(): string {
const randomUuid = (() => {
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
return cryptoObj.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
})();
return `cid_probe_${randomUuid}`;
}
function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
@@ -54,7 +45,7 @@ async function buildClientConfig(
connection: HostConnection,
serverId?: string
): Promise<DaemonClientConfig> {
const clientId = createProbeClientId();
const clientId = await getOrCreateClientId();
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const base = {
clientId,
+3 -1
View File
@@ -17,7 +17,9 @@
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
"prepack": "npm run build",
"typecheck": "tsc --noEmit",
"test:e2e": "npx zx tests/run-all.ts",
"test": "npm run test:local",
"test:local": "tsx tests/run-all.ts",
"test:e2e": "npm run test:local",
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
},
"dependencies": {
+101 -17
View File
@@ -1,8 +1,9 @@
import { spawn, spawnSync } from 'node:child_process'
import { closeSync, existsSync, openSync, readFileSync, rmSync } from 'node:fs'
import { closeSync, existsSync, openSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { loadConfig, resolvePaseoHome } from '@getpaseo/server'
import { tryConnectToDaemon } from '../../utils/client.js'
export interface DaemonStartOptions {
port?: string
@@ -219,19 +220,65 @@ function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
}
}
async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
return false
}
try {
return signalProcess(pid, signal)
} catch (err) {
const code = readNodeErrnoCode(err)
if (code === 'EPERM') {
return true
}
throw err
}
}
function signalProcessGroupSafely(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
return false
}
if (process.platform === 'win32') {
return signalProcessSafely(pid, signal)
}
try {
process.kill(-pid, signal)
return true
} catch (err) {
const code = readNodeErrnoCode(err)
if (code === 'ESRCH') {
return signalProcessSafely(pid, signal)
}
if (code === 'EPERM') {
return true
}
throw err
}
}
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (!isProcessRunning(pid)) {
return true
}
await sleep(PID_POLL_INTERVAL_MS)
}
return !isProcessRunning(pid)
}
type LifecycleShutdownAttempt =
| { requested: true }
| { requested: false; reason: string }
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export function resolveLocalPaseoHome(home?: string): string {
return resolvePaseoHome(envWithHome(home))
}
@@ -384,6 +431,41 @@ export function startLocalDaemonForeground(options: DaemonStartOptions): number
return result.status ?? 1
}
async function requestLifecycleShutdown(
state: LocalDaemonState,
timeoutMs: number
): Promise<LifecycleShutdownAttempt> {
const host = resolveTcpHostFromListen(state.listen)
if (!host) {
return {
requested: false,
reason: 'daemon listen target is not TCP, falling back to owner PID signal',
}
}
const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) })
if (!client) {
return {
requested: false,
reason: `daemon websocket at ${host} is not reachable, falling back to owner PID signal`,
}
}
try {
await client.shutdownServer()
return { requested: true }
} catch (error) {
return {
requested: false,
reason: `daemon lifecycle shutdown request failed (${getErrorMessage(
error
)}), falling back to owner PID signal`,
}
} finally {
await client.close().catch(() => undefined)
}
}
export async function stopLocalDaemon(
options: StopLocalDaemonOptions = {}
): Promise<StopLocalDaemonResult> {
@@ -405,7 +487,12 @@ export async function stopLocalDaemon(
}
const pid = state.pidInfo.pid
const signaled = signalProcess(pid, 'SIGTERM')
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs)
const lifecycleRequested = shutdownAttempt.requested
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason
let forced = false
if (!lifecycleRequested) {
const signaled = signalProcessSafely(pid, 'SIGTERM')
if (!signaled) {
return {
action: 'not_running',
@@ -415,18 +502,13 @@ export async function stopLocalDaemon(
message: 'Daemon process was already stopped',
}
}
}
let forced = false
let stopped = await waitForExit(pid, timeoutMs)
let stopped = await waitForPidExit(pid, timeoutMs)
if (!stopped && options.force) {
forced = true
const killSent = signalProcess(pid, 'SIGKILL')
if (killSent) {
stopped = await waitForExit(pid, KILL_TIMEOUT_MS)
} else {
stopped = true
}
signalProcessGroupSafely(pid, 'SIGKILL')
stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS)
}
if (!stopped) {
@@ -435,13 +517,15 @@ export async function stopLocalDaemon(
)
}
rmSync(state.pidPath, { force: true })
return {
action: 'stopped',
home: state.home,
pid,
forced,
message: forced ? 'Daemon was force-stopped' : 'Daemon stopped gracefully',
message: forced
? 'Daemon owner process was force-stopped'
: lifecycleRequested
? 'Daemon stopped gracefully'
: fallbackMessage ?? 'Daemon stopped via owner PID signal',
}
}
+123 -42
View File
@@ -4,7 +4,8 @@
* Phase 15: Provider Command Tests
*
* Tests provider commands for listing providers and models.
* Provider data is static and doesn't require a running daemon.
* Provider ls data is static, while provider models are fetched via daemon integration.
* This test uses an isolated daemon to avoid coupling to a user's long-running daemon.
*
* Tests:
* - provider --help shows subcommands
@@ -19,16 +20,51 @@
*/
import assert from 'node:assert'
import { $ } from 'zx'
$.verbose = false
import { createE2ETestContext } from './helpers/test-daemon.ts'
console.log('=== Provider Commands ===\n')
type ProviderModel = {
model: string
id: string
description?: string
}
let claudeModelIdsFromJson: string[] = []
let claudeModelsFromJson: ProviderModel[] = []
const ctx = await createE2ETestContext({ timeout: 120000 })
async function runProviderModelsJson(
provider: 'claude' | 'codex' | 'opencode'
): Promise<ProviderModel[]> {
const transientNeedles = ['transport closed', 'timed out', 'timeout', 'socket', 'econn']
for (let attempt = 1; attempt <= 3; attempt++) {
const result = await ctx.paseo(['provider', 'models', provider, '--json'])
if (result.exitCode === 0) {
return JSON.parse(result.stdout.trim()) as ProviderModel[]
}
const combined = `${result.stdout}\n${result.stderr}`
const normalized = combined.toLowerCase()
const isTransient = transientNeedles.some((needle) => normalized.includes(needle))
if (!isTransient || attempt === 3) {
assert.fail(`provider models ${provider} should exit 0\n${combined}`)
}
await new Promise((resolve) => setTimeout(resolve, 250 * attempt))
}
assert.fail(`provider models ${provider} exhausted retries`)
}
try {
// Test 1: provider --help shows subcommands
{
console.log('Test 1: provider --help shows subcommands')
const result = await $`npx paseo provider --help`.nothrow()
const result = await ctx.paseo(['provider', '--help'])
assert.strictEqual(result.exitCode, 0, 'provider --help should exit 0')
assert(result.stdout.includes('ls'), 'help should mention ls')
assert(result.stdout.includes('models'), 'help should mention models')
@@ -38,7 +74,7 @@ console.log('=== Provider Commands ===\n')
// Test 2: provider ls lists all providers
{
console.log('Test 2: provider ls lists all providers')
const result = await $`npx paseo provider ls`.nothrow()
const result = await ctx.paseo(['provider', 'ls'])
assert.strictEqual(result.exitCode, 0, 'provider ls should exit 0')
assert(result.stdout.includes('claude'), 'output should include claude')
assert(result.stdout.includes('codex'), 'output should include codex')
@@ -50,7 +86,7 @@ console.log('=== Provider Commands ===\n')
// Test 3: provider ls --json outputs valid JSON
{
console.log('Test 3: provider ls --json outputs valid JSON')
const result = await $`npx paseo provider ls --json`.nothrow()
const result = await ctx.paseo(['provider', 'ls', '--json'])
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const data = JSON.parse(result.stdout.trim())
assert(Array.isArray(data), 'output should be an array')
@@ -64,7 +100,7 @@ console.log('=== Provider Commands ===\n')
// Test 4: provider ls --quiet outputs provider names only
{
console.log('Test 4: provider ls --quiet outputs provider names only')
const result = await $`npx paseo provider ls --quiet`.nothrow()
const result = await ctx.paseo(['provider', 'ls', '--quiet'])
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const lines = result.stdout.trim().split('\n')
assert.strictEqual(lines.length, 3, 'should have 3 lines')
@@ -74,42 +110,66 @@ console.log('=== Provider Commands ===\n')
console.log('✓ provider ls --quiet outputs provider names only\n')
}
// Test 5: provider models claude lists claude models
// Test 5: provider models claude lists canonical model aliases
{
console.log('Test 5: provider models claude lists claude models')
const result = await $`npx paseo provider models claude`.nothrow()
assert.strictEqual(result.exitCode, 0, 'provider models claude should exit 0')
assert(result.stdout.includes('claude-sonnet-4-20250514'), 'output should include claude-sonnet-4')
assert(result.stdout.includes('claude-opus-4-20250514'), 'output should include claude-opus-4')
assert(result.stdout.includes('claude-3-5-haiku-20241022'), 'output should include claude-haiku')
console.log('✓ provider models claude lists claude models\n')
console.log('Test 5: provider models claude lists canonical model aliases')
const data = await runProviderModelsJson('claude')
assert.strictEqual(data.length, 3, 'should have exactly 3 claude models')
const byId = new Map(data.map((model) => [model.id, model]))
const ids = [...byId.keys()].sort()
assert.deepStrictEqual(ids, ['default', 'haiku', 'opus'], 'claude IDs should be default/opus/haiku')
assert.strictEqual(byId.get('default')?.model, 'Sonnet 4.5', 'default claude alias should map to Sonnet 4.5')
assert.strictEqual(byId.get('opus')?.model, 'Opus 4.6', 'opus claude alias should map to Opus 4.6')
assert.strictEqual(byId.get('haiku')?.model, 'Haiku 4.5', 'haiku claude alias should map to Haiku 4.5')
assert(
(byId.get('default')?.description ?? '').includes('Best for everyday tasks'),
'default claude description should mention everyday tasks'
)
assert(
(byId.get('opus')?.description ?? '').includes('Most capable'),
'opus claude description should mention most capable'
)
assert(
(byId.get('haiku')?.description ?? '').includes('Fastest'),
'haiku claude description should mention fastest'
)
console.log('✓ provider models claude lists canonical model aliases\n')
}
// Test 6: provider models codex lists codex models
// Test 6: provider models codex includes concrete codex model IDs
{
console.log('Test 6: provider models codex lists codex models')
const result = await $`npx paseo provider models codex`.nothrow()
assert.strictEqual(result.exitCode, 0, 'provider models codex should exit 0')
assert(result.stdout.includes('o3-mini'), 'output should include o3-mini')
assert(result.stdout.includes('o4-mini'), 'output should include o4-mini')
console.log('✓ provider models codex lists codex models\n')
console.log('Test 6: provider models codex includes concrete codex model IDs')
const data = await runProviderModelsJson('codex')
assert(data.length >= 6, 'codex model list should include current codex lineup')
const ids = data.map((m) => m.id)
assert.strictEqual(new Set(ids).size, ids.length, 'codex model IDs should be unique')
assert(ids.includes('gpt-5.3-codex'), 'codex output should include gpt-5.3-codex')
assert(ids.includes('gpt-5.3-codex-spark'), 'codex output should include gpt-5.3-codex-spark')
assert(ids.includes('gpt-5.1-codex-max'), 'codex output should include gpt-5.1-codex-max')
assert(ids.includes('gpt-5.1-codex-mini'), 'codex output should include gpt-5.1-codex-mini')
console.log('✓ provider models codex includes concrete codex model IDs\n')
}
// Test 7: provider models opencode lists opencode models
// Test 7: provider models opencode returns namespaced model IDs
{
console.log('Test 7: provider models opencode lists opencode models')
const result = await $`npx paseo provider models opencode`.nothrow()
assert.strictEqual(result.exitCode, 0, 'provider models opencode should exit 0')
// opencode supports both claude and codex models
assert(result.stdout.includes('claude-sonnet-4-20250514'), 'output should include claude models')
assert(result.stdout.includes('o3-mini'), 'output should include codex models')
console.log('✓ provider models opencode lists opencode models\n')
console.log('Test 7: provider models opencode returns namespaced model IDs')
const data = await runProviderModelsJson('opencode')
assert(data.length >= 3, 'opencode model list should not be empty')
const ids = data.map((m) => m.id)
assert(data.every((m) => m.id.includes('/')), 'opencode model IDs should be provider-namespaced')
assert(ids.includes('opencode/gpt-5-nano'), 'opencode output should include opencode/gpt-5-nano')
assert(ids.includes('openai/o3-mini'), 'opencode output should include openai/o3-mini')
assert(
ids.includes('openai/gpt-5.3-codex-spark'),
'opencode output should include openai/gpt-5.3-codex-spark'
)
console.log('✓ provider models opencode returns namespaced model IDs\n')
}
// Test 8: provider models unknown fails with error
{
console.log('Test 8: provider models unknown fails with error')
const result = await $`npx paseo provider models unknown`.nothrow()
const result = await ctx.paseo(['provider', 'models', 'unknown'])
assert.notStrictEqual(result.exitCode, 0, 'should fail for unknown provider')
const output = result.stdout + result.stderr
assert(
@@ -122,26 +182,47 @@ console.log('=== Provider Commands ===\n')
// Test 9: provider models --json outputs valid JSON
{
console.log('Test 9: provider models --json outputs valid JSON')
const result = await $`npx paseo provider models claude --json`.nothrow()
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const data = JSON.parse(result.stdout.trim())
const data = await runProviderModelsJson('claude')
assert(Array.isArray(data), 'output should be an array')
assert.strictEqual(data.length, 3, 'should have 3 models for claude')
assert(data.every((m: { model: string; id: string }) => m.model && m.id), 'each model should have name and id')
assert.strictEqual(data.length, 3, 'should have exactly 3 models for claude')
assert(data.every((m) => m.model && m.id), 'each model should have name and id')
const byId = new Map(data.map((model) => [model.id, model]))
const ids = [...byId.keys()].sort()
assert.deepStrictEqual(ids, ['default', 'haiku', 'opus'], 'claude JSON IDs should be deterministic')
assert.strictEqual(byId.get('default')?.model, 'Sonnet 4.5', 'default claude alias should map to Sonnet 4.5')
assert.strictEqual(byId.get('opus')?.model, 'Opus 4.6', 'opus claude alias should map to Opus 4.6')
assert.strictEqual(byId.get('haiku')?.model, 'Haiku 4.5', 'haiku claude alias should map to Haiku 4.5')
claudeModelIdsFromJson = data.map((m) => m.id)
claudeModelsFromJson = data
console.log('✓ provider models --json outputs valid JSON\n')
}
// Test 10: provider models --quiet outputs model IDs only
{
console.log('Test 10: provider models --quiet outputs model IDs only')
const result = await $`npx paseo provider models claude --quiet`.nothrow()
assert(claudeModelIdsFromJson.length > 0, 'claude model IDs should be captured from --json output')
const result = await ctx.paseo(['provider', 'models', 'claude', '--quiet'])
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const lines = result.stdout.trim().split('\n')
const lines = result.stdout.trim().split('\n').filter(Boolean)
assert.strictEqual(lines.length, 3, 'should have 3 lines')
assert(lines.includes('claude-sonnet-4-20250514'), 'should include claude-sonnet-4')
assert(lines.includes('claude-opus-4-20250514'), 'should include claude-opus-4')
assert(lines.includes('claude-3-5-haiku-20241022'), 'should include claude-haiku')
assert.deepStrictEqual(
[...lines].sort(),
[...claudeModelIdsFromJson].sort(),
'--quiet should print the same model IDs returned by --json'
)
assert.deepStrictEqual(
[...lines].sort(),
['default', 'haiku', 'opus'],
'--quiet should print canonical claude IDs'
)
assert(
claudeModelsFromJson.some((m) => m.id === 'default'),
'captured --json output should still include default claude model id'
)
console.log('✓ provider models --quiet outputs model IDs only\n')
}
} finally {
await ctx.stop()
}
console.log('=== All provider tests passed ===')
+15 -12
View File
@@ -8,7 +8,7 @@
* - Help and argument parsing
* - Validation for required update fields
* - Graceful daemon connection errors
* - Top-level alias support (`paseo update`)
* - Top-level daemon update alias behavior (`paseo update`)
*/
import assert from 'node:assert'
@@ -16,13 +16,14 @@ import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false
console.log('=== Agent Update Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
// Resolve an available local port so daemon-not-running checks stay deterministic.
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
@@ -104,25 +105,27 @@ try {
console.log('✓ agent --help shows update subcommand\n')
}
// Test 7: top-level update alias --help works
// Test 7: top-level update alias --help shows daemon update options
{
console.log('Test 7: top-level update --help works')
console.log('Test 7: top-level update --help shows daemon update options')
const result = await $`npx paseo update --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'update --help should exit 0')
assert(result.stdout.includes('--name'), 'help should mention --name flag')
assert(result.stdout.includes('--label'), 'help should mention --label flag')
console.log('✓ top-level update --help works\n')
assert(result.stdout.includes('--home'), 'help should mention --home flag')
assert(result.stdout.includes('--yes'), 'help should mention --yes flag')
assert(result.stdout.includes('daemon update'), 'help should mention daemon update alias')
console.log('✓ top-level update --help shows daemon update options\n')
}
// Test 8: top-level update alias accepts flags
// Test 8: top-level update alias accepts daemon update flags
{
console.log('Test 8: top-level update alias accepts flags')
console.log('Test 8: top-level update alias accepts daemon update flags')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo update abc123 --name "Alias Name" --host localhost:${port}`.nothrow()
await $`PASEO_HOME=${paseoHome} npx paseo update --home ${paseoHome} --yes --help`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept top-level update flags')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ top-level update alias accepts flags\n')
assert.strictEqual(result.exitCode, 0, 'update alias help with flags should exit 0')
console.log('✓ top-level update alias accepts daemon update flags\n')
}
} finally {
// Clean up temp directory
+10 -9
View File
@@ -5,24 +5,25 @@ import { readFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false
function randomPort(): number {
return 10000 + Math.floor(Math.random() * 50000)
}
console.log('=== Onboarding Command ===\n')
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-onboard-home-'))
const port = randomPort()
const port = await getAvailablePort()
try {
console.log('Test 1: `paseo` runs blocking onboarding and prints pairing info')
const onboard =
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npm run -s cli --`.nothrow()
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npx paseo`.nothrow()
assert.strictEqual(onboard.exitCode, 0, `onboard should succeed: ${onboard.stderr}`)
assert.strictEqual(
onboard.exitCode,
0,
`onboard should succeed:\nstdout:\n${onboard.stdout}\nstderr:\n${onboard.stderr}`
)
assert(onboard.stdout.includes('Scan to pair'), 'onboard output should include scan header')
assert(onboard.stdout.includes('Pairing link'), 'onboard output should include pairing link header')
assert(onboard.stdout.includes('#offer='), 'onboard output should include pairing offer URL')
@@ -34,7 +35,7 @@ try {
assert(onboard.stdout.includes(join(paseoHome, 'daemon.log')), 'onboard output should include daemon log path')
const status =
await $`PASEO_HOME=${paseoHome} npm run -s cli -- daemon status --home ${paseoHome}`.nothrow()
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --home ${paseoHome}`.nothrow()
assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`)
assert(status.stdout.includes('running'), 'daemon should be running when onboarding exits')
console.log('✓ onboarding prints pairing info and waits for daemon readiness\n')
@@ -57,7 +58,7 @@ try {
)
console.log('✓ non-interactive run persisted voice disabled choices\n')
} finally {
await $`PASEO_HOME=${paseoHome} npm run -s cli -- daemon stop --home ${paseoHome} --force`.nothrow()
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
}
@@ -11,10 +11,16 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false
const pollIntervalMs = 100
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
@@ -68,7 +74,7 @@ type DaemonStatus = {
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
if (result.exitCode !== 0) {
return { status: null, pid: null }
}
@@ -104,7 +110,7 @@ async function waitFor(
console.log('=== Daemon Stop (supervisor regression) ===\n')
const port = 10000 + Math.floor(Math.random() * 50000)
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-stop-supervisor-'))
const cliRoot = join(import.meta.dirname, '..')
@@ -118,6 +124,7 @@ try {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: 'false',
@@ -159,7 +166,7 @@ try {
console.log('Test 2: `paseo daemon stop` should stop without respawn')
const stopResult =
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --json`.nothrow()
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow()
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`)
const stopJson = JSON.parse(stopResult.stdout) as { action?: unknown }
assert.strictEqual(stopJson.action, 'stopped', 'stop should report stopped action')
@@ -189,6 +196,14 @@ try {
const statusAfterStop = await readDaemonStatus(paseoHome)
assert.strictEqual(statusAfterStop.status, 'stopped', 'daemon should remain stopped after stop command')
assert(
recentSupervisorLogs.includes('Shutdown requested by worker. Stopping worker...'),
`stop should request lifecycle shutdown from daemon worker, logs:\n${recentSupervisorLogs}`
)
assert(
!recentSupervisorLogs.includes('cli_shutdown'),
`supervisor logs should not route shutdown by reason string:\n${recentSupervisorLogs}`
)
console.log('✓ stop leaves supervised daemon stopped (no respawn)\n')
} finally {
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
@@ -200,7 +215,7 @@ try {
)
}
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
}
@@ -0,0 +1,217 @@
#!/usr/bin/env npx tsx
/**
* Regression: a single SIGINT sent to a supervised daemon-runner must allow
* graceful daemon lifecycle shutdown to complete (no early forced exit path).
*/
import assert from 'node:assert'
import { spawn, type ChildProcess } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false
const pollIntervalMs = 100
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function signalProcessGroup(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
if (process.platform === 'win32') {
try {
process.kill(pid, signal)
return true
} catch {
return false
}
}
try {
process.kill(-pid, signal)
return true
} catch {
return false
}
}
type DaemonStatus = {
status: string | null
pid: number | null
}
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
if (result.exitCode !== 0) {
return { status: null, pid: null }
}
try {
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
const status = typeof parsed.status === 'string' ? parsed.status : null
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null
return { status, pid }
} catch {
return { status: null, pid: null }
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await check()) {
return
}
await sleep(pollIntervalMs)
}
throw new Error(message)
}
type ExitResult = {
code: number | null
signal: NodeJS.Signals | null
}
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('timed out waiting for process exit'))
}, timeoutMs)
processRef.once('exit', (code, signal) => {
clearTimeout(timeout)
resolve({ code, signal })
})
})
}
console.log('=== Daemon SIGINT (supervisor regression) ===\n')
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-sigint-supervisor-'))
const cliRoot = join(import.meta.dirname, '..')
let supervisorProcess: ChildProcess | null = null
let recentSupervisorLogs = ''
try {
console.log('Test 1: start daemon-runner in dev mode with isolated PASEO_HOME')
supervisorProcess = spawn('npx', ['tsx', '../server/scripts/daemon-runner.ts', '--dev'], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: 'false',
CI: 'true',
},
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
})
supervisorProcess.stdout?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
supervisorProcess.stderr?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
},
120000,
'daemon did not become running in time'
)
console.log('✓ supervised daemon started\n')
console.log('Test 2: single SIGINT should shutdown gracefully without forced exit')
const exitPromise = waitForProcessExit(supervisorProcess, 30000)
const signaledGroup = signalProcessGroup(supervisorProcess.pid ?? -1, 'SIGINT')
if (!signaledGroup) {
supervisorProcess.kill('SIGINT')
}
const exit = await exitPromise
assert.strictEqual(exit.signal, null, `supervisor should exit cleanly, got signal=${exit.signal}`)
assert.strictEqual(exit.code, 0, `supervisor should exit with status 0, got code=${exit.code}`)
await waitFor(async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'stopped'
}, 15000, 'daemon status did not transition to stopped after SIGINT')
assert(
!recentSupervisorLogs.includes('Forcing exit...'),
`worker entered forced-exit path during single SIGINT:\n${recentSupervisorLogs}`
)
assert(
!recentSupervisorLogs.includes("Forcing shutdown - HTTP server didn't close in time"),
`worker hit shutdown timeout during single SIGINT:\n${recentSupervisorLogs}`
)
console.log('✓ single SIGINT completed graceful shutdown without forced exit\n')
} finally {
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
const signaledGroup = signalProcessGroup(supervisorProcess.pid, 'SIGTERM')
if (!signaledGroup) {
supervisorProcess.kill('SIGTERM')
}
await waitFor(() => !isProcessRunning(supervisorProcess!.pid ?? -1), 5000, 'supervisor cleanup timed out').catch(
() => {
const killedGroup = signalProcessGroup(supervisorProcess!.pid ?? -1, 'SIGKILL')
if (!killedGroup) {
supervisorProcess?.kill('SIGKILL')
}
}
)
}
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
}
if (recentSupervisorLogs.trim().length === 0) {
console.log('(no supervisor logs captured)')
}
console.log('=== Supervisor SIGINT regression test passed ===')
@@ -0,0 +1,115 @@
#!/usr/bin/env npx tsx
/**
* Regression: `paseo daemon stop` must only act on daemon ownership state and
* must not discover/kill processes via home-scoped `ps` command heuristics.
*/
import assert from 'node:assert'
import { spawn, type ChildProcess } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
$.verbose = false
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function waitForRunning(pid: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (isProcessRunning(pid)) {
return
}
await sleep(50)
}
throw new Error(`Process ${pid} did not become running in time`)
}
console.log('=== Daemon Stop Ownership Regression ===\n')
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-stop-ownership-'))
let decoyProcess: ChildProcess | null = null
try {
console.log('Test 1: start decoy process with daemon-like command markers')
decoyProcess = spawn(
process.execPath,
[
'-e',
// Keep the process alive long enough for stop command assertions.
'setInterval(() => {}, 1000)',
'daemon-runner.ts',
],
{
env: {
...process.env,
PASEO_HOME: paseoHome,
},
stdio: 'ignore',
detached: process.platform !== 'win32',
}
)
decoyProcess.unref()
const decoyPid = decoyProcess.pid
assert(Number.isInteger(decoyPid) && (decoyPid ?? 0) > 0, 'decoy pid should exist')
await waitForRunning(decoyPid!, 5000)
console.log(`✓ decoy process started (${decoyPid})\n`)
console.log('Test 2: daemon stop should report not_running and leave decoy untouched')
const stopResult =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow()
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`)
const parsed = JSON.parse(stopResult.stdout) as { action?: unknown }
assert.strictEqual(parsed.action, 'not_running', `stop should not target decoy process: ${stopResult.stdout}`)
assert(isProcessRunning(decoyPid!), 'decoy process must remain alive after stop')
console.log('✓ stop is ownership-driven and does not kill decoy process\n')
} finally {
if (decoyProcess?.pid && isProcessRunning(decoyProcess.pid)) {
try {
process.kill(decoyProcess.pid, 'SIGTERM')
} catch {
// ignore
}
await sleep(100)
if (isProcessRunning(decoyProcess.pid)) {
try {
process.kill(decoyProcess.pid, 'SIGKILL')
} catch {
// ignore
}
}
}
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== Daemon stop ownership regression test passed ===')
@@ -0,0 +1,214 @@
#!/usr/bin/env npx tsx
/**
* Regression: app-style restart requests must trigger supervised worker restart
* (worker PID changes) while keeping the daemon healthy.
*/
import assert from 'node:assert'
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { tryConnectToDaemon } from '../src/utils/client.ts'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false
const pollIntervalMs = 100
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function readWorkerPid(supervisorPid: number): number | null {
if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) {
return null
}
const result = spawnSync('ps', ['ax', '-o', 'pid=,ppid='], { encoding: 'utf8' })
if (result.status !== 0 || result.error) {
return null
}
for (const line of result.stdout.split('\n')) {
const trimmed = line.trim()
if (!trimmed) {
continue
}
const [pidToken, ppidToken] = trimmed.split(/\s+/)
const pid = Number.parseInt(pidToken ?? '', 10)
const ppid = Number.parseInt(ppidToken ?? '', 10)
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) {
continue
}
if (ppid === supervisorPid && pid > 0) {
return pid
}
}
return null
}
type DaemonStatus = {
status: string | null
pid: number | null
}
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
if (result.exitCode !== 0) {
return { status: null, pid: null }
}
try {
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
const status = typeof parsed.status === 'string' ? parsed.status : null
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null
return { status, pid }
} catch {
return { status: null, pid: null }
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await check()) {
return
}
await sleep(pollIntervalMs)
}
throw new Error(message)
}
console.log('=== Daemon Restart (supervisor regression) ===\n')
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-restart-supervisor-'))
const cliRoot = join(import.meta.dirname, '..')
const host = `127.0.0.1:${port}`
let supervisorProcess: ChildProcess | null = null
let recentSupervisorLogs = ''
try {
console.log('Test 1: start daemon-runner in dev mode with isolated PASEO_HOME')
supervisorProcess = spawn('npx', ['tsx', '../server/scripts/daemon-runner.ts', '--dev'], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: 'false',
CI: 'true',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
supervisorProcess.stdout?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
supervisorProcess.stderr?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
},
120000,
'daemon did not become running in time'
)
const statusBeforeRestart = await readDaemonStatus(paseoHome)
const supervisorPid = statusBeforeRestart.pid
assert.strictEqual(statusBeforeRestart.status, 'running', 'daemon should be running before restart')
assert(supervisorPid !== null, 'supervisor pid should exist once daemon starts')
assert(isProcessRunning(supervisorPid), 'supervisor process should be running')
const workerPidBeforeRestart = readWorkerPid(supervisorPid)
assert(workerPidBeforeRestart !== null, 'supervisor should have a worker process before restart')
assert(isProcessRunning(workerPidBeforeRestart), 'worker process should be running before restart')
console.log(`✓ daemon running with supervisor ${supervisorPid} and worker ${workerPidBeforeRestart}\n`)
console.log('Test 2: app-style restart request should restart worker and keep daemon healthy')
const client = await tryConnectToDaemon({ host, timeout: 5000 })
assert(client, 'daemon client should connect')
try {
const restartAck = await client.restartServer('settings_update')
assert.strictEqual(restartAck.status, 'restart_requested', 'restart request should be acknowledged')
} finally {
await client?.close().catch(() => undefined)
}
await waitFor(() => {
const workerPid = readWorkerPid(supervisorPid)
return workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid)
}, 20000, 'worker pid did not change after restart request')
const workerPidAfterRestart = readWorkerPid(supervisorPid)
assert(workerPidAfterRestart !== null, 'worker process should exist after restart')
assert.notStrictEqual(
workerPidAfterRestart,
workerPidBeforeRestart,
'worker pid should change after restart'
)
const statusAfterRestart = await readDaemonStatus(paseoHome)
assert.strictEqual(statusAfterRestart.status, 'running', 'daemon should stay running after restart')
assert.strictEqual(statusAfterRestart.pid, supervisorPid, 'supervisor pid should remain stable across restart')
assert(
recentSupervisorLogs.includes('Restart requested by worker. Stopping worker for restart...'),
`restart should route through supervisor restart intent, logs:\n${recentSupervisorLogs}`
)
console.log('✓ app-style restart keeps daemon healthy and restarts worker\n')
} finally {
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
supervisorProcess.kill('SIGTERM')
await waitFor(() => !isProcessRunning(supervisorProcess!.pid ?? -1), 5000, 'supervisor cleanup timed out').catch(
() => {
supervisorProcess?.kill('SIGKILL')
}
)
}
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
}
if (recentSupervisorLogs.trim().length === 0) {
console.log('(no supervisor logs captured)')
}
console.log('=== Supervisor restart regression test passed ===')
@@ -0,0 +1,202 @@
#!/usr/bin/env npx tsx
/**
* Regression: unsupervised restart request should gracefully stop and exit 0,
* so an external owner can decide whether to respawn.
*/
import assert from 'node:assert'
import { spawn, type ChildProcess } from 'node:child_process'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { tryConnectToDaemon } from '../src/utils/client.ts'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false
const pollIntervalMs = 100
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
type DaemonStatus = {
status: string | null
pid: number | null
}
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
if (result.exitCode !== 0) {
return { status: null, pid: null }
}
try {
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
const status = typeof parsed.status === 'string' ? parsed.status : null
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null
return { status, pid }
} catch {
return { status: null, pid: null }
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await check()) {
return
}
await sleep(pollIntervalMs)
}
throw new Error(message)
}
type ExitResult = {
code: number | null
signal: NodeJS.Signals | null
}
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('timed out waiting for process exit'))
}, timeoutMs)
processRef.once('exit', (code, signal) => {
clearTimeout(timeout)
resolve({ code, signal })
})
})
}
async function readPidLockPid(paseoHome: string): Promise<number | null> {
const pidPath = join(paseoHome, 'paseo.pid')
try {
const content = await readFile(pidPath, 'utf-8')
const parsed = JSON.parse(content) as { pid?: unknown }
if (typeof parsed.pid !== 'number' || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
return null
}
return parsed.pid
} catch {
return null
}
}
console.log('=== Daemon Restart (unsupervised regression) ===\n')
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-restart-unsupervised-'))
const cliRoot = join(import.meta.dirname, '..')
const host = `127.0.0.1:${port}`
let daemonProcess: ChildProcess | null = null
try {
console.log('Test 1: start unsupervised daemon worker directly')
daemonProcess = spawn(
process.execPath,
[...process.execArgv, '--import', 'tsx', '../server/src/server/index.ts'],
{
cwd: cliRoot,
env: {
...process.env,
...testEnv,
// This test validates direct unsupervised worker ownership semantics.
// Agent-orchestrated shells may export PASEO_PID_LOCK_MODE=external,
// which would delegate lock ownership away from this process and make
// daemon status checks fail to observe a running owner PID.
PASEO_PID_LOCK_MODE: 'self',
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: 'false',
CI: 'true',
},
stdio: ['ignore', 'pipe', 'pipe'],
}
)
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
},
120000,
'daemon did not become running in time'
)
const statusBeforeRestart = await readDaemonStatus(paseoHome)
assert.strictEqual(statusBeforeRestart.status, 'running', 'daemon should be running before restart')
assert(daemonProcess.pid, 'unsupervised daemon process pid should exist')
assert.strictEqual(statusBeforeRestart.pid, daemonProcess.pid, 'status pid should match daemon process pid')
const lockPid = await readPidLockPid(paseoHome)
assert.strictEqual(lockPid, daemonProcess.pid, 'unsupervised worker should own pid lock')
console.log(`✓ unsupervised daemon started with pid ${daemonProcess.pid}\n`)
console.log('Test 2: restart request should gracefully stop and exit code 0')
const client = await tryConnectToDaemon({ host, timeout: 5000 })
assert(client, 'daemon client should connect')
const exitPromise = waitForProcessExit(daemonProcess, 30000)
try {
const restartAck = await client.restartServer('settings_update')
assert.strictEqual(restartAck.status, 'restart_requested', 'restart request should be acknowledged')
} finally {
await client?.close().catch(() => undefined)
}
const exit = await exitPromise
assert.strictEqual(exit.signal, null, `daemon should exit cleanly, got signal=${exit.signal}`)
assert.strictEqual(exit.code, 0, `daemon should exit with status 0, got code=${exit.code}`)
await waitFor(async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'stopped'
}, 15000, 'daemon status did not transition to stopped after unsupervised restart request')
console.log('✓ unsupervised restart exited cleanly with code 0\n')
} finally {
if (daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) {
daemonProcess.kill('SIGTERM')
await waitFor(() => !isProcessRunning(daemonProcess!.pid ?? -1), 5000, 'daemon cleanup timed out').catch(
() => {
daemonProcess?.kill('SIGKILL')
}
)
}
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== Unsupervised restart regression test passed ===')
+32
View File
@@ -0,0 +1,32 @@
import { createServer } from 'node:net'
/**
* Reserve and release an ephemeral TCP port, returning the port number.
* Tests can use this to reduce port collision flakiness when spawning daemons.
*/
export async function getAvailablePort(): Promise<number> {
return new Promise<number>((resolve, reject) => {
const server = createServer()
server.unref()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.close(() => {
reject(new Error('Failed to resolve an available TCP port'))
})
return
}
const { port } = address
server.close((error) => {
if (error) {
reject(error)
return
}
resolve(port)
})
})
})
}
+145 -39
View File
@@ -2,10 +2,10 @@
* Test Daemon Helper
*
* Provides utilities for launching real Paseo daemons in E2E tests.
* Each test gets an isolated daemon on a random port with its own PASEO_HOME.
* Each test gets an isolated daemon on an available local port with its own PASEO_HOME.
*
* CRITICAL RULES (from design doc):
* 1. Port: Random port in 20000-30000 range - NEVER use 6767 (production)
* 1. Port: Use an available ephemeral local port - NEVER use 6767 (production)
* 2. Protocol: WebSocket ONLY - daemon has no HTTP endpoints
* 3. Temp dirs: Create temp directories for PASEO_HOME and agent --cwd
* 4. Model: Always use claude provider with haiku model for fast, cheap tests
@@ -17,9 +17,10 @@ import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { ChildProcess, spawn } from 'child_process'
import { getAvailablePort } from './network.ts'
export interface TestDaemonContext {
/** Random port for test daemon (never 6767) */
/** Available local port for test daemon (never 6767) */
port: number
/** WebSocket URL for connecting to daemon */
wsUrl: string
@@ -35,6 +36,117 @@ export interface TestDaemonContext {
stop: () => Promise<void>
}
const TEST_DAEMON_ENV_DEFAULTS: Record<string, string> = {
PASEO_RELAY_ENABLED: 'false',
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
const TEST_DAEMON_HOST = '127.0.0.1'
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024
const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt(
process.env.PASEO_TEST_OUTPUT_CAPTURE_BYTES ?? `${DEFAULT_OUTPUT_CAPTURE_LIMIT}`,
10
)
type OutputCapture = {
value: string
truncated: boolean
}
function createOutputCapture(): OutputCapture {
return { value: '', truncated: false }
}
function appendOutputCapture(target: OutputCapture, chunk: Buffer): void {
const next = target.value + chunk.toString()
if (next.length <= TEST_OUTPUT_CAPTURE_LIMIT) {
target.value = next
return
}
target.truncated = true
target.value = next.slice(next.length - TEST_OUTPUT_CAPTURE_LIMIT)
}
function formatOutputCapture(target: OutputCapture): string {
if (!target.truncated) {
return target.value
}
return `[truncated; showing last ${TEST_OUTPUT_CAPTURE_LIMIT} chars]\n${target.value}`
}
function readNodeErrnoCode(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return undefined
}
return typeof (error as { code?: unknown }).code === 'string'
? ((error as { code: string }).code)
: undefined
}
function signalProcessTree(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
if (process.platform !== 'win32') {
try {
process.kill(-pid, signal)
return true
} catch (error) {
const code = readNodeErrnoCode(error)
if (code === 'ESRCH') {
return false
}
}
}
try {
process.kill(pid, signal)
return true
} catch (error) {
const code = readNodeErrnoCode(error)
if (code === 'ESRCH') {
return false
}
throw error
}
}
async function terminateProcessTree(processRef: ChildProcess, timeoutMs: number): Promise<void> {
const pid = processRef.pid
if (!Number.isInteger(pid) || pid <= 0) {
return
}
if (processRef.exitCode !== null || processRef.signalCode !== null) {
return
}
signalProcessTree(pid, 'SIGTERM')
await new Promise<void>((resolve) => {
let settled = false
const finish = () => {
if (settled) {
return
}
settled = true
clearTimeout(timeoutId)
resolve()
}
const timeoutId = setTimeout(() => {
signalProcessTree(pid, 'SIGKILL')
finish()
}, timeoutMs)
processRef.once('exit', () => {
finish()
})
})
}
/**
* Generate a random port for test daemon
* Uses range 20000-30000 to avoid conflicts
@@ -73,7 +185,7 @@ async function waitForDaemonReady(
const { exitCode } = await runPaseoCli(
{
port,
wsUrl: `ws://127.0.0.1:${port}`,
wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
paseoHome: '',
workDir: '',
process: null,
@@ -110,13 +222,13 @@ export async function startTestDaemon(options?: {
workDir?: string
timeout?: number
}): Promise<TestDaemonContext> {
const port = options?.port ?? getRandomPort()
const port = options?.port ?? await getAvailablePort()
const { paseoHome, workDir } = options?.paseoHome && options?.workDir
? { paseoHome: options.paseoHome, workDir: options.workDir }
: await createTempDirs()
const timeout = options?.timeout ?? 30000
const wsUrl = `ws://127.0.0.1:${port}`
const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`
// Find the CLI entry point - use the source file directly with tsx
const cliDir = join(import.meta.dirname, '..', '..')
@@ -126,43 +238,30 @@ export async function startTestDaemon(options?: {
const daemonProcess = spawn('npx', ['tsx', cliSrcPath, 'daemon', 'start', '--foreground'], {
env: {
...process.env,
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
// Disable relay for tests
PASEO_RELAY_ENABLED: 'false',
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
// Force no TTY to prevent QR code output
CI: 'true',
},
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
detached: process.platform !== 'win32',
})
let stdout = ''
let stderr = ''
const stdout = createOutputCapture()
const stderr = createOutputCapture()
daemonProcess.stdout?.on('data', (data) => {
stdout += data.toString()
appendOutputCapture(stdout, data)
})
daemonProcess.stderr?.on('data', (data) => {
stderr += data.toString()
appendOutputCapture(stderr, data)
})
const cleanup = async () => {
if (daemonProcess && !daemonProcess.killed) {
daemonProcess.kill('SIGTERM')
// Wait for process to exit
await new Promise<void>((resolve) => {
const timeoutId = setTimeout(() => {
daemonProcess.kill('SIGKILL')
resolve()
}, 5000)
daemonProcess.on('exit', () => {
clearTimeout(timeoutId)
resolve()
})
})
if (daemonProcess) {
await terminateProcessTree(daemonProcess, 5000)
}
// Clean up temp directories
@@ -191,8 +290,9 @@ export async function startTestDaemon(options?: {
daemonProcess.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`Daemon process exited with code ${code}`)
if (stderr) {
console.error('Daemon stderr:', stderr)
const stderrText = formatOutputCapture(stderr)
if (stderrText) {
console.error('Daemon stderr:', stderrText)
}
}
})
@@ -215,7 +315,9 @@ export async function startTestDaemon(options?: {
// Daemon failed to start - clean up and rethrow
await cleanup()
const message = err instanceof Error ? err.message : String(err)
throw new Error(`Failed to start test daemon: ${message}\nStdout: ${stdout}\nStderr: ${stderr}`)
throw new Error(
`Failed to start test daemon: ${message}\nStdout: ${formatOutputCapture(stdout)}\nStderr: ${formatOutputCapture(stderr)}`
)
}
return ctx
@@ -245,26 +347,30 @@ export async function runPaseoCli(
const proc = spawn('npx', ['tsx', cliSrcPath, ...args], {
env: {
...process.env,
PASEO_HOST: `localhost:${ctx.port}`,
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
PASEO_HOME: ctx.paseoHome,
},
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
})
let stdout = ''
let stderr = ''
const stdout = createOutputCapture()
const stderr = createOutputCapture()
proc.stdout?.on('data', (data) => {
stdout += data.toString()
appendOutputCapture(stdout, data)
})
proc.stderr?.on('data', (data) => {
stderr += data.toString()
appendOutputCapture(stderr, data)
})
const timeoutId = setTimeout(() => {
proc.kill('SIGKILL')
if (proc.pid) {
signalProcessTree(proc.pid, 'SIGKILL')
}
reject(new Error(`CLI command timed out after ${timeout}ms: paseo ${args.join(' ')}`))
}, timeout)
@@ -272,8 +378,8 @@ export async function runPaseoCli(
clearTimeout(timeoutId)
resolve({
exitCode: code ?? 1,
stdout,
stderr,
stdout: formatOutputCapture(stdout),
stderr: formatOutputCapture(stderr),
})
})
+70 -4
View File
@@ -8,11 +8,31 @@
*/
import { $ } from 'zx'
import { readdir } from 'fs/promises'
import { readdir, writeFile } from 'fs/promises'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const args = process.argv.slice(2)
const testEnvDefaults = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
let jsonOutputPath: string | null = null
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg === '--json-output') {
const value = args[i + 1]
if (!value) {
throw new Error('--json-output requires a file path')
}
jsonOutputPath = value
i++
continue
}
}
$.verbose = false
@@ -26,8 +46,29 @@ const testFiles = files
.sort()
if (testFiles.length === 0) {
console.log('⚠️ No test files found')
process.exit(0)
console.log('❌ No test files found')
if (jsonOutputPath) {
await writeFile(
jsonOutputPath,
JSON.stringify(
{
suite: 'cli-local',
command: 'npm run test:local --workspace=@getpaseo/cli',
counts: {
passed: 0,
failed: 0,
skipped: 0,
},
skippedTests: [],
failures: [],
error: 'No test files found',
},
null,
2
) + '\n'
)
}
process.exit(1)
}
console.log(`Found ${testFiles.length} test file(s):\n`)
@@ -49,7 +90,7 @@ for (const testFile of testFiles) {
console.log('─'.repeat(50))
try {
const result = await $`npx tsx ${testPath}`.nothrow()
const result = await $`PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnvDefaults.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnvDefaults.PASEO_VOICE_MODE_ENABLED} npx tsx ${testPath}`.nothrow()
if (result.exitCode === 0) {
console.log(`\n✅ ${testName} PASSED`)
passed++
@@ -89,4 +130,29 @@ if (failures.length > 0) {
}
console.log()
if (jsonOutputPath) {
await writeFile(
jsonOutputPath,
JSON.stringify(
{
suite: 'cli-local',
command: 'npm run test:local --workspace=@getpaseo/cli',
counts: {
passed,
failed,
skipped: 0,
},
skippedTests: [],
failures: failures.map(({ test, error }) => ({
test,
error: error.split('\n')[0] ?? '',
})),
},
null,
2
) + '\n'
)
}
process.exit(failed > 0 ? 1 : 0)
+41 -2
View File
@@ -14,6 +14,39 @@ import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
const TEST_ENV_DEFAULTS = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
function killPidTree(pid: number, signal: NodeJS.Signals): void {
if (!Number.isInteger(pid) || pid <= 0) {
return
}
if (process.platform !== 'win32') {
try {
process.kill(-pid, signal)
return
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ESRCH') {
return
}
}
}
try {
process.kill(pid, signal)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== 'ESRCH') {
throw error
}
}
}
export interface TestContext {
/** Random port for test daemon (never 6767) */
port: number
@@ -72,7 +105,7 @@ export async function startDaemon(
paseoHome: string
): Promise<ProcessPromise> {
$.verbose = false
const daemon = $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} paseo daemon start --foreground`.nothrow()
const daemon = $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow()
return daemon
}
@@ -86,14 +119,20 @@ export async function createTestContext(): Promise<TestContext> {
// Helper to run CLI commands against test daemon
const paseo = (args: string[]): ProcessPromise => {
$.verbose = false
return $`PASEO_HOST=localhost:${port} paseo ${args}`.nothrow()
return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow()
}
// Cleanup function
const cleanup = async (): Promise<void> => {
if (ctx.daemon) {
if (typeof ctx.daemon.pid === 'number') {
killPidTree(ctx.daemon.pid, 'SIGTERM')
await sleep(250)
killPidTree(ctx.daemon.pid, 'SIGKILL')
} else {
ctx.daemon.kill()
}
}
await rm(paseoHome, { recursive: true, force: true })
await rm(workDir, { recursive: true, force: true })
}
+85 -29
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { WebSocket } from "ws";
import net from "node:net";
import { spawn, type ChildProcess } from "node:child_process";
import { createRequire } from "node:module";
import { Buffer } from "node:buffer";
import {
generateKeyPair,
@@ -14,6 +15,9 @@ import {
const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0");
const shouldRunRelayE2e = process.env.FORCE_RELAY_E2E === "1" || nodeMajor < 25;
const wranglerCliPath = createRequire(import.meta.url).resolve("wrangler/bin/wrangler.js");
const STARTUP_HOOK_TIMEOUT_MS = 90_000;
const SHUTDOWN_TIMEOUT_MS = 10_000;
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
@@ -30,9 +34,43 @@ async function getAvailablePort(): Promise<number> {
});
}
async function waitForServer(port: number, timeout = 15000): Promise<void> {
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function spawnRelayDevServer(port: number): ChildProcess {
return spawn(
process.execPath,
[
wranglerCliPath,
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(port),
"--live-reload=false",
"--show-interactive-dev-session=false",
],
{
cwd: process.cwd(),
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
}
);
}
function assertRelayStillRunning(relayProcess: ChildProcess): void {
if (relayProcess.exitCode !== null) {
throw new Error(`relay process exited before startup completed (code: ${relayProcess.exitCode})`);
}
}
async function waitForServer(port: number, relayProcess: ChildProcess, timeout = 15000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
assertRelayStillRunning(relayProcess);
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, "127.0.0.1", () => {
@@ -43,15 +81,20 @@ async function waitForServer(port: number, timeout = 15000): Promise<void> {
});
return;
} catch {
await new Promise((r) => setTimeout(r, 100));
await sleep(100);
}
}
throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
}
async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promise<void> {
async function waitForRelayWebSocketReady(
port: number,
relayProcess: ChildProcess,
timeout = 60000
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
assertRelayStillRunning(relayProcess);
const serverId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const probeUrl = `ws://127.0.0.1:${port}/ws?serverId=${serverId}&role=server&v=2`;
const opened = await new Promise<boolean>((resolve) => {
@@ -73,37 +116,44 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
if (opened) {
return;
}
await new Promise((r) => setTimeout(r, 250));
await sleep(250);
}
throw new Error(`Relay WebSocket endpoint not ready on port ${port} within ${timeout}ms`);
}
async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
if (relayProcess.exitCode !== null) {
return;
}
relayProcess.kill("SIGTERM");
const start = Date.now();
while (relayProcess.exitCode === null && Date.now() - start < SHUTDOWN_TIMEOUT_MS) {
await sleep(50);
}
if (relayProcess.exitCode !== null) {
return;
}
relayProcess.kill("SIGKILL");
const killStart = Date.now();
while (relayProcess.exitCode === null && Date.now() - killStart < 2000) {
await sleep(50);
}
if (relayProcess.exitCode === null) {
throw new Error("relay process did not exit after SIGTERM/SIGKILL");
}
}
(shouldRunRelayE2e ? describe : describe.skip)("E2E Relay with E2EE", () => {
let relayPort: number;
let relayProcess: ChildProcess | null = null;
beforeAll(async () => {
relayPort = await getAvailablePort();
relayProcess = spawn(
"npx",
[
"wrangler",
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
],
{
cwd: process.cwd(),
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
}
);
relayProcess = spawnRelayDevServer(relayPort);
relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data.toString().split("\n").filter((l) => l.trim());
@@ -120,16 +170,22 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
}
});
await waitForServer(relayPort, 30000);
await waitForRelayWebSocketReady(relayPort, 60000);
});
try {
await waitForServer(relayPort, relayProcess, 30000);
await waitForRelayWebSocketReady(relayPort, relayProcess, 60000);
} catch (error) {
await stopRelayProcess(relayProcess);
relayProcess = null;
throw error;
}
}, STARTUP_HOOK_TIMEOUT_MS);
afterAll(async () => {
if (relayProcess) {
relayProcess.kill("SIGTERM");
await stopRelayProcess(relayProcess);
relayProcess = null;
}
});
}, SHUTDOWN_TIMEOUT_MS);
it("full flow: daemon and client exchange encrypted messages through relay", { timeout: 90_000 }, async () => {
const serverId = "test-session-" + Date.now();
+11 -4
View File
@@ -45,12 +45,19 @@
"speech:download": "tsx scripts/download-speech-models.ts",
"speech:tts:matrix": "tsx scripts/generate-sherpa-tts-matrix.ts",
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "vitest run",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 --minWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
"test:integration:local": "vitest run local.e2e.test.ts",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:mobile": "playwright test --project='Mobile Chrome'"
"test:e2e": "vitest run e2e.test.ts --maxWorkers=1 --minWorkers=1 --exclude \"**/*.real.e2e.test.ts\" --exclude \"**/*.local.e2e.test.ts\"",
"test:e2e:all": "vitest run e2e.test.ts --maxWorkers=1 --minWorkers=1",
"test:e2e:real": "npm run test:integration:real",
"test:e2e:local": "npm run test:integration:local",
"test:e2e:ui": "vitest --ui e2e.test.ts"
},
"dependencies": {
"@ai-sdk/openai": "2.0.52",
+45 -3
View File
@@ -1,5 +1,8 @@
import { fileURLToPath } from "url";
import { existsSync } from "node:fs";
import { loadConfig } from "../src/server/config.js";
import { acquirePidLock, PidLockError, releasePidLock } from "../src/server/pid-lock.js";
import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { runSupervisor } from "./supervisor.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
@@ -52,10 +55,42 @@ function resolveWorkerExecArgv(workerEntry: string): string[] {
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
}
async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
const workerEnv: NodeJS.ProcessEnv = {
...process.env,
PASEO_PID_LOCK_MODE: "external",
};
applySherpaLoaderEnv(process.env);
applySherpaLoaderEnv(workerEnv);
const paseoHome = resolvePaseoHome(workerEnv);
const daemonConfig = loadConfig(paseoHome, { env: workerEnv });
try {
await acquirePidLock(paseoHome, daemonConfig.listen, {
ownerPid: process.pid,
});
} catch (error) {
if (error instanceof PidLockError) {
process.stderr.write(`${error.message}\n`);
process.exit(1);
return;
}
throw error;
}
let lockReleased = false;
const releaseLock = async (): Promise<void> => {
if (lockReleased) {
return;
}
lockReleased = true;
await releasePidLock(paseoHome, {
ownerPid: process.pid,
});
};
runSupervisor({
name: "DaemonRunner",
@@ -64,8 +99,15 @@ runSupervisor({
: "Starting daemon worker (IPC restart enabled)",
resolveWorkerEntry: () => workerEntry,
workerArgs: config.workerArgs,
workerEnv: process.env,
workerEnv,
workerExecArgv: resolveWorkerExecArgv(workerEntry),
restartOnCrash: config.devMode,
shutdownReasons: ["cli_shutdown"],
onSupervisorExit: releaseLock,
});
}
void main().catch((error) => {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});
+14 -16
View File
@@ -1,26 +1,24 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "url";
import { existsSync } from "node:fs";
import dotenv from "dotenv";
import { runSupervisor } from "./supervisor.js";
dotenv.config({
path: fileURLToPath(new URL("../.env", import.meta.url)),
quiet: true,
});
const WORKER_ENTRY = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
if (!existsSync(WORKER_ENTRY)) {
throw new Error(`Dev worker entry not found: ${WORKER_ENTRY}`);
const daemonRunnerEntry = fileURLToPath(new URL("./daemon-runner.ts", import.meta.url));
const result = spawnSync(
process.execPath,
[...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
{
stdio: "inherit",
env: process.env,
}
);
if (result.error) {
throw result.error;
}
runSupervisor({
name: "DevRunner",
startupMessage: "Starting server worker (crash restarts enabled)",
resolveWorkerEntry: () => WORKER_ENTRY,
workerArgs: process.argv.slice(2),
workerEnv: process.env,
// Always run worker with tsx so dev server uses TypeScript sources directly.
workerExecArgv: ["--import", "tsx"],
restartOnCrash: true,
shutdownReasons: ["cli_shutdown"],
});
process.exit(result.status ?? 1);
@@ -5,7 +5,7 @@ import { z } from "zod";
const server = new McpServer({ name: "paseo-test-mcp", version: "1.0.0" });
server.tool(
"echo",
"paseo_roundtrip_text",
{ text: z.string() },
async ({ text }) => ({
content: [{ type: "text", text: `ECHO:${text}` }],
@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs'
import { describe, expect, test } from 'vitest'
describe('supervision parity', () => {
test('has exactly one runtime callsite for runSupervisor', () => {
const daemonRunner = readFileSync(new URL('./daemon-runner.ts', import.meta.url), 'utf8')
const devRunner = readFileSync(new URL('./dev-runner.ts', import.meta.url), 'utf8')
const daemonRunnerCalls = (daemonRunner.match(/\brunSupervisor\s*\(/g) ?? []).length
const devRunnerCalls = (devRunner.match(/\brunSupervisor\s*\(/g) ?? []).length
expect(daemonRunnerCalls + devRunnerCalls).toBe(1)
})
})
@@ -0,0 +1,13 @@
import { readFileSync } from 'node:fs'
import { describe, expect, test } from 'vitest'
describe('supervisor lifecycle intents', () => {
test('uses explicit shutdown and restart IPC intents', () => {
const source = readFileSync(new URL('./supervisor.ts', import.meta.url), 'utf8')
const legacyShutdownReason = ['cli', 'shutdown'].join('_')
expect(source).toContain('"paseo:shutdown"')
expect(source).toContain('"paseo:restart"')
expect(source).not.toContain(legacyShutdownReason)
})
})
+47 -17
View File
@@ -1,6 +1,10 @@
import { fork, type ChildProcess } from "child_process";
type RestartMessage = {
type WorkerLifecycleMessage =
| {
type: "paseo:shutdown";
}
| {
type: "paseo:restart";
reason?: string;
};
@@ -13,24 +17,32 @@ type SupervisorOptions = {
workerEnv?: NodeJS.ProcessEnv;
workerExecArgv?: string[];
restartOnCrash?: boolean;
shutdownReasons?: string[];
onSupervisorExit?: () => Promise<void> | void;
};
function describeExit(code: number | null, signal: NodeJS.Signals | null): string {
return signal ?? (typeof code === "number" ? `code ${code}` : "unknown");
}
function isRestartMessage(msg: unknown): msg is RestartMessage {
return (
typeof msg === "object" &&
msg !== null &&
"type" in msg &&
(msg as { type?: unknown }).type === "paseo:restart"
);
function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
if (typeof msg !== "object" || msg === null || !("type" in msg)) {
return null;
}
const type = (msg as { type?: unknown }).type;
if (type === "paseo:shutdown") {
return { type: "paseo:shutdown" };
}
if (type === "paseo:restart") {
const reason = (msg as { reason?: unknown }).reason;
return {
type: "paseo:restart",
...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}),
};
}
return null;
}
export function runSupervisor(options: SupervisorOptions): void {
const shutdownReasons = new Set(options.shutdownReasons ?? ["cli_shutdown"]);
const restartOnCrash = options.restartOnCrash ?? false;
const workerArgs = options.workerArgs ?? process.argv.slice(2);
const workerEnv = options.workerEnv ?? process.env;
@@ -39,11 +51,27 @@ export function runSupervisor(options: SupervisorOptions): void {
let child: ChildProcess | null = null;
let restarting = false;
let shuttingDown = false;
let exiting = false;
const log = (message: string): void => {
process.stderr.write(`[${options.name}] ${message}\n`);
};
const exitSupervisor = (code: number): void => {
if (exiting) {
return;
}
exiting = true;
Promise.resolve(options.onSupervisorExit?.())
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
log(`Supervisor exit cleanup failed: ${message}`);
})
.finally(() => {
process.exit(code);
});
};
const spawnWorker = () => {
let workerEntry: string;
try {
@@ -52,7 +80,7 @@ export function runSupervisor(options: SupervisorOptions): void {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`Failed to resolve worker entry: ${message}`);
process.exit(1);
exitSupervisor(1);
return;
}
@@ -63,12 +91,13 @@ export function runSupervisor(options: SupervisorOptions): void {
});
child.on("message", (msg: unknown) => {
if (!isRestartMessage(msg)) {
const lifecycleMessage = parseLifecycleMessage(msg);
if (!lifecycleMessage) {
return;
}
if (msg.reason && shutdownReasons.has(msg.reason)) {
requestShutdown(`Shutdown requested by worker (${msg.reason})`);
if (lifecycleMessage.type === "paseo:shutdown") {
requestShutdown("Shutdown requested by worker");
return;
}
@@ -80,7 +109,8 @@ export function runSupervisor(options: SupervisorOptions): void {
if (shuttingDown) {
log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`);
process.exit(0);
exitSupervisor(0);
return;
}
if (restarting || (restartOnCrash && code !== 0 && code !== null)) {
@@ -91,7 +121,7 @@ export function runSupervisor(options: SupervisorOptions): void {
}
log(`Worker exited (${exitDescriptor}). Supervisor exiting.`);
process.exit(typeof code === "number" ? code : 0);
exitSupervisor(typeof code === "number" ? code : 0);
});
};
@@ -112,7 +142,7 @@ export function runSupervisor(options: SupervisorOptions): void {
restarting = false;
log(`${reason}. Stopping worker...`);
if (!child) {
process.exit(0);
exitSupervisor(0);
return;
}
child.kill("SIGTERM");
@@ -206,6 +206,119 @@ describe('DaemonClient', () => {
expect(client.getConnectionState().status).toBe('disposed')
})
test('sends explicit shutdown_server_request via shutdownServer', 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 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(mock.sent).toHaveLength(1)
const request = JSON.parse(mock.sent[0]) as {
type: 'session'
message: {
type: string
requestId: string
}
}
expect(request.message).toEqual({
type: 'shutdown_server_request',
requestId: 'req-shutdown-1',
})
mock.triggerMessage(
wrapSessionMessage({
type: 'status',
payload: {
status: 'shutdown_requested',
clientId: 'clsk_unit_test',
requestId: 'req-shutdown-1',
},
})
)
await expect(promise).resolves.toEqual({
status: 'shutdown_requested',
clientId: 'clsk_unit_test',
requestId: 'req-shutdown-1',
})
})
test('restartServer remains restart-only and sends restart_server_request', 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.restartServer('settings_update', 'req-restart-1')
expect(mock.sent).toHaveLength(1)
const request = JSON.parse(mock.sent[0]) as {
type: 'session'
message: {
type: string
reason?: string
requestId: string
}
}
expect(request.message).toEqual({
type: 'restart_server_request',
reason: 'settings_update',
requestId: 'req-restart-1',
})
mock.triggerMessage(
wrapSessionMessage({
type: 'status',
payload: {
status: 'restart_requested',
clientId: 'clsk_unit_test',
reason: 'settings_update',
requestId: 'req-restart-1',
},
})
)
await expect(promise).resolves.toEqual({
status: 'restart_requested',
clientId: 'clsk_unit_test',
reason: 'settings_update',
requestId: 'req-restart-1',
})
})
test('transitions out of connecting when connect timeout elapses', async () => {
vi.useFakeTimers()
try {
+31 -1
View File
@@ -5,6 +5,7 @@ import {
AgentRefreshedStatusPayloadSchema,
AgentResumedStatusPayloadSchema,
RestartRequestedStatusPayloadSchema,
ShutdownRequestedStatusPayloadSchema,
SessionInboundMessageSchema,
type WSWelcomeMessage,
WSOutboundMessageSchema,
@@ -275,6 +276,7 @@ export type FetchAgentTimelineOptions = {
type AgentRefreshedStatusPayload = z.infer<typeof AgentRefreshedStatusPayloadSchema>
type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema>
type ShutdownRequestedStatusPayload = z.infer<typeof ShutdownRequestedStatusPayloadSchema>
type FetchAgentsPayload = Extract<
SessionOutboundMessage,
{ type: 'fetch_agents_response' }
@@ -1542,6 +1544,33 @@ export class DaemonClient {
})
}
async shutdownServer(requestId?: string): Promise<ShutdownRequestedStatusPayload> {
const resolvedRequestId = this.createRequestId(requestId)
const message = SessionInboundMessageSchema.parse({
type: 'shutdown_server_request',
requestId: resolvedRequestId,
})
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== 'status') {
return null
}
const shutdown = ShutdownRequestedStatusPayloadSchema.safeParse(msg.payload)
if (!shutdown.success) {
return null
}
if (shutdown.data.requestId !== resolvedRequestId) {
return null
}
return shutdown.data
},
})
}
// ============================================================================
// Audio / Voice
// ============================================================================
@@ -2203,7 +2232,8 @@ export class DaemonClient {
cwd: options?.cwd,
},
responseType: 'list_provider_models_response',
timeout: 30000,
// Provider SDK cold starts (especially model discovery) can exceed 30s.
timeout: 45000,
})
}
@@ -280,7 +280,7 @@ describe("agent MCP end-to-end (offline)", () => {
});
await waitForPathExists({
targetPath: path.join(worktreePath, "dev-terminal.txt"),
timeoutMs: 15000,
timeoutMs: 30000,
});
} finally {
if (agentId) {
@@ -93,7 +93,7 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini" },
{ provider: "codex", model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
] as const;
@@ -8,57 +8,83 @@
* the Claude Agent SDK's command capabilities.
*/
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { ClaudeAgentClient } from "./claude-agent.js";
import type { AgentSession, AgentSessionConfig, AgentSlashCommand } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { useTempClaudeConfigDir } from "../../test-utils/claude-config.js";
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
(hasClaudeCredentials ? describe : describe.skip)("ClaudeAgentSession Commands", () => {
let client: ClaudeAgentClient;
let session: AgentSession;
let session: AgentSession | null = null;
let commands: AgentSlashCommand[] = [];
let restoreClaudeConfigDir: (() => void) | null = null;
let tempCwd: string | null = null;
// Mock config for testing - uses plan mode to avoid actual tool execution
const testConfig: AgentSessionConfig = {
const buildTestConfig = (cwd: string): AgentSessionConfig => ({
provider: "claude",
cwd: process.cwd(),
cwd,
modeId: "plan",
};
});
beforeAll(async () => {
restoreClaudeConfigDir = useTempClaudeConfigDir();
const rawTempDir = mkdtempSync(path.join(os.tmpdir(), "claude-agent-commands-"));
try {
tempCwd = realpathSync(rawTempDir);
} catch {
tempCwd = rawTempDir;
}
client = new ClaudeAgentClient({ logger: createTestLogger() });
session = await client.createSession(buildTestConfig(tempCwd));
if (typeof session.listCommands !== "function") {
throw new Error("Claude test session does not expose listCommands");
}
commands = await session.listCommands();
});
afterAll(async () => {
try {
if (session) {
await session.close();
}
} finally {
session = null;
if (tempCwd) {
rmSync(tempCwd, { recursive: true, force: true });
tempCwd = null;
}
restoreClaudeConfigDir?.();
restoreClaudeConfigDir = null;
}
});
describe("listCommands()", () => {
it("should return an array of AgentSlashCommand objects", async () => {
session = await client.createSession(testConfig);
if (!session) {
throw new Error("Claude test session not initialized");
}
// The session should have a listCommands method
expect(typeof session.listCommands).toBe("function");
const commands = await session.listCommands!();
// Should be an array
expect(Array.isArray(commands)).toBe(true);
// Should have at least some built-in commands
expect(commands.length).toBeGreaterThan(0);
await session.close();
}, 30000);
it("should have valid AgentSlashCommand structure for all commands", async () => {
session = await client.createSession(testConfig);
const commands = await session.listCommands!();
if (!session) {
throw new Error("Claude test session not initialized");
}
// Verify all commands have valid structure
for (const cmd of commands) {
@@ -72,22 +98,19 @@ const hasClaudeCredentials =
// Names should NOT have the / prefix (that's added when executing)
expect(cmd.name.startsWith("/")).toBe(false);
}
await session.close();
}, 30000);
it("should include user-defined skills", async () => {
session = await client.createSession(testConfig);
if (!session) {
throw new Error("Claude test session not initialized");
}
const commands = await session.listCommands!();
const commandNames = commands.map((cmd) => cmd.name);
// Should have at least one command (skills are loaded from user/project settings)
// The exact commands depend on what skills are configured
expect(commands.length).toBeGreaterThan(0);
expect(commandNames).toContain("rewind");
await session.close();
}, 30000);
});
@@ -23,6 +23,7 @@ import type { AgentStreamEventPayload } from "../../messages.js";
import type {
AgentProvider,
AgentPermissionRequest,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
@@ -48,6 +49,14 @@ function tmpCwd(): string {
}
}
async function closeSessionAndCleanup(
session: AgentSession | null | undefined,
cwd: string
): Promise<void> {
await session?.close();
rmSync(cwd, { recursive: true, force: true });
}
async function autoApprove(session: Awaited<ReturnType<ClaudeAgentClient["createSession"]>>, event: AgentStreamEvent) {
if (event.type === "permission_requested") {
await session.respondToPermission(event.request.id, { behavior: "allow" });
@@ -288,15 +297,16 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 1024 });
const session = await client.createSession(config);
try {
const marker = "CLAUDE_ACK_TOKEN";
const result = await session.run(
`Reply with the exact text ${marker} and then stop.`
);
expect(result.finalText).toContain(marker);
await session.close();
rmSync(cwd, { recursive: true, force: true });
} finally {
await closeSessionAndCleanup(session, cwd);
}
},
120_000
);
@@ -309,6 +319,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config);
try {
const events = session.stream(
"Think step by step about the pros and cons of single-file tests, but only share a short plan."
);
@@ -326,9 +337,9 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
}
expect(sawReasoning).toBe(true);
await session.close();
rmSync(cwd, { recursive: true, force: true });
} finally {
await closeSessionAndCleanup(session, cwd);
}
},
120_000
);
@@ -421,6 +432,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 1024 });
const session = await client.createSession(config);
try {
const events = session.stream(
"First run a Bash command to print the working directory, then use your editor tools (not the shell) to create a file named tool-test.txt in the current directory that contains exactly the text 'hello world'. Report 'done' after the write finishes."
);
@@ -476,9 +488,9 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const filePath = path.join(cwd, "tool-test.txt");
expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf8")).toContain("hello world");
await session.close();
rmSync(cwd, { recursive: true, force: true });
} finally {
await closeSessionAndCleanup(session, cwd);
}
},
180_000
);
@@ -775,6 +787,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config);
try {
const first = await session.run("Respond only with the word alpha.");
expect(first.finalText.toLowerCase()).toContain("alpha");
@@ -782,9 +795,9 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
"Without adding any explanations, repeat exactly the same word you just said."
);
expect(second.finalText.toLowerCase()).toContain("alpha");
await session.close();
rmSync(cwd, { recursive: true, force: true });
} finally {
await closeSessionAndCleanup(session, cwd);
}
},
120_000
);
@@ -853,7 +866,9 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const client = new ClaudeAgentClient({ logger });
const config = buildConfig(cwd, { maxThinkingTokens: 1024 });
const session = await client.createSession(config);
let resumed: AgentSession | null = null;
try {
// Store a specific word in a file to create history and enable recall
const timestamp = Date.now();
const secretWord = `XYZZY${timestamp}PLUGH`;
@@ -885,7 +900,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
expect(await waitForHistoryContains(historyPaths, secretWord)).toBe(true);
// Resume and verify context is preserved
const resumed = await client.resumeSession(handle!, { cwd });
resumed = await client.resumeSession(handle!, { cwd });
// Verify history is emitted on resume
const historyEvents: AgentStreamEvent[] = [];
@@ -921,9 +936,10 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
resumedResult.finalText.includes("XYZZY") ||
resumedResult.finalText.includes("PLUGH");
expect(recalledSomething).toBe(true);
await resumed.close();
rmSync(cwd, { recursive: true, force: true });
} finally {
await resumed?.close();
await closeSessionAndCleanup(session, cwd);
}
},
180_000
);
@@ -936,6 +952,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 1024 });
const session = await client.createSession(config);
try {
const modes = await session.getAvailableModes();
expect(modes.map((m) => m.id)).toContain("plan");
@@ -946,9 +963,9 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
"Just reply with the word PLAN to confirm you're still responsive."
);
expect(result.finalText.toLowerCase()).toContain("plan");
await session.close();
rmSync(cwd, { recursive: true, force: true });
} finally {
await closeSessionAndCleanup(session, cwd);
}
},
120_000
);
@@ -960,6 +977,8 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const client = new ClaudeAgentClient({ logger });
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config);
try {
await session.setMode("plan");
const events = session.stream(
@@ -993,9 +1012,9 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const filePath = path.join(cwd, "dummy.txt");
expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf8")).toContain("plan-test");
await session.close();
rmSync(cwd, { recursive: true, force: true });
} finally {
await closeSessionAndCleanup(session, cwd);
}
},
180_000
);
@@ -1008,6 +1027,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config);
try {
const prompt = [
"You must call the AskUserQuestion tool exactly once and wait for the user's answer.",
"Create one question with header 'color', prompt 'Choose a color', and options Blue and Red.",
@@ -1066,9 +1086,9 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
expect(sawResolvedAllow).toBe(true);
expect(session.getPendingPermissions()).toHaveLength(0);
expect(sawDone).toBe(true);
await session.close();
rmSync(cwd, { recursive: true, force: true });
} finally {
await closeSessionAndCleanup(session, cwd);
}
},
180_000
);
@@ -1271,6 +1291,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config);
try {
const events = session.stream(
"Use the Task tool to launch a sub-agent that reads the current directory listing. " +
"The sub-agent should run 'ls' in the shell and report the result. " +
@@ -1312,9 +1333,9 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const taskLines = lines.filter((l) => l.includes("[Task]"));
// Each Task callId should appear at most once in curated output
expect(taskLines.length).toBeLessThanOrEqual(taskCalls.length);
await session.close();
rmSync(cwd, { recursive: true, force: true });
} finally {
await closeSessionAndCleanup(session, cwd);
}
},
180_000
);
@@ -74,6 +74,34 @@ const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = [
"user",
"project",
];
const CLAUDE_MODEL_DISCOVERY_TIMEOUT_MS = 20_000;
const CLAUDE_MODEL_QUERY_SHUTDOWN_TIMEOUT_MS = 2_000;
type ClaudeFallbackModel = {
id: string;
label: string;
description: string;
isDefault?: boolean;
};
const CLAUDE_FALLBACK_MODELS: readonly ClaudeFallbackModel[] = [
{
id: "default",
label: "Sonnet 4.5",
description: "Best for everyday tasks",
isDefault: true,
},
{
id: "opus",
label: "Opus 4.6",
description: "Most capable model for deep analysis and complex code changes",
},
{
id: "haiku",
label: "Haiku 4.5",
description: "Fastest Claude model for lightweight tasks",
},
];
type TurnState = "idle" | "foreground" | "autonomous";
@@ -129,6 +157,66 @@ type ForegroundTurnState = {
type ClaudeModelFamily = "sonnet" | "opus" | "haiku";
function withTimeout<T>(params: {
promise: Promise<T>;
timeoutMs: number;
label: string;
}): Promise<T> {
const { promise, timeoutMs, label } = params;
return new Promise<T>((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timeoutHandle);
resolve(value);
},
(error) => {
clearTimeout(timeoutHandle);
reject(error);
}
);
});
}
function toClaudeModelDefinition(params: {
id: string;
label: string;
description?: string;
isDefault?: boolean;
}): AgentModelDefinition {
return {
provider: "claude",
id: params.id,
label: params.label,
description: params.description,
...(params.isDefault ? { isDefault: true } : {}),
thinkingOptions: [
{ id: "off", label: "Off", isDefault: true },
{ id: "on", label: "On" },
],
defaultThinkingOptionId: "off",
metadata: params.description
? {
description: params.description,
}
: undefined,
};
}
function buildClaudeFallbackModels(): AgentModelDefinition[] {
return CLAUDE_FALLBACK_MODELS.map((model) =>
toClaudeModelDefinition({
id: model.id,
label: model.label,
description: model.description,
isDefault: model.isDefault,
})
);
}
function normalizeClaudeModelLabel(model: ModelInfo): string {
const fallback = model.displayName?.trim() || model.value;
const prefix = model.description?.split(/[·•]/)[0]?.trim() || "";
@@ -190,15 +278,6 @@ function inferClaudeModelFamilyFromText(
return null;
}
function inferClaudeModelFamily(model: ModelInfo): ClaudeModelFamily | null {
const descriptionPrefix = model.description?.split(/[·•]/)[0]?.trim() || null;
return (
inferClaudeModelFamilyFromText(descriptionPrefix) ??
inferClaudeModelFamilyFromText(model.displayName) ??
inferClaudeModelFamilyFromText(model.value)
);
}
function pickFamilyAliasModelId(
familyAliases: ReadonlyMap<ClaudeModelFamily, string> | null | undefined,
family: ClaudeModelFamily
@@ -1468,25 +1547,38 @@ export class ClaudeAgentClient implements AgentClient {
options: this.applyRuntimeSettings(claudeOptions),
});
try {
const models: ModelInfo[] = await claudeQuery.supportedModels();
return models.map((model) => ({
provider: "claude" as const,
const models: ModelInfo[] = await withTimeout({
promise: claudeQuery.supportedModels(),
timeoutMs: CLAUDE_MODEL_DISCOVERY_TIMEOUT_MS,
label: "Claude model discovery",
});
if (models.length === 0) {
this.logger.warn(
"Claude SDK returned an empty model catalog; using fallback Claude model aliases"
);
return buildClaudeFallbackModels();
}
return models.map((model) =>
toClaudeModelDefinition({
id: model.value,
label: normalizeClaudeModelLabel(model),
description: model.description,
thinkingOptions: [
{ id: "off", label: "Off", isDefault: true },
{ id: "on", label: "On" },
],
defaultThinkingOptionId: "off",
metadata: {
description: model.description,
},
}));
})
);
} catch (error) {
this.logger.warn(
{ err: error },
"Failed to fetch Claude model catalog from SDK; using fallback Claude model aliases"
);
return buildClaudeFallbackModels();
} finally {
if (typeof claudeQuery.return === "function") {
try {
await claudeQuery.return();
await withTimeout({
promise: claudeQuery.return(),
timeoutMs: CLAUDE_MODEL_QUERY_SHUTDOWN_TIMEOUT_MS,
label: "Claude model query shutdown",
});
} catch {
// ignore shutdown errors
}
@@ -2262,38 +2354,6 @@ class ClaudeAgentSession implements AgentSession {
this.userMessageIds.push(messageId);
}
private async primeSelectableModelIds(query: Query): Promise<void> {
try {
const models = await query.supportedModels();
const ids: string[] = [];
const familyAliases = new Map<ClaudeModelFamily, string>();
for (const model of models) {
const modelId = normalizeModelIdCandidate(model.value);
if (!modelId) {
continue;
}
ids.push(modelId);
const family = inferClaudeModelFamily(model);
if (family && !familyAliases.has(family)) {
familyAliases.set(family, modelId);
}
}
this.selectableModelIds = new Set(ids);
this.selectableModelFamilyAliases = familyAliases.size > 0 ? familyAliases : null;
this.logger.debug(
{
modelIds: ids,
modelFamilyAliases: Object.fromEntries(familyAliases),
},
"Primed Claude selectable model IDs"
);
} catch (error) {
this.selectableModelIds = null;
this.selectableModelFamilyAliases = null;
this.logger.warn({ err: error }, "Failed to prime Claude selectable model IDs");
}
}
private async ensureQuery(): Promise<Query> {
if (this.query && !this.queryRestartNeeded) {
return this.query;
@@ -2315,12 +2375,10 @@ class ClaudeAgentSession implements AgentSession {
);
this.input = input;
this.query = query({ prompt: input, options });
// Do not block query readiness on control-plane calls. We need `next()` to
// start immediately so autonomous wake events are not missed between turns.
void this.awaitWithTimeout(
this.primeSelectableModelIds(this.query),
"prime selectable model ids"
);
// Do not kick off background control-plane queries here. Methods like
// supportedCommands()/setPermissionMode() may execute immediately after
// ensureQuery() (for listCommands()/setMode()), and sharing the same query
// control plane can cause those calls to wait behind supportedModels().
return this.query;
}
@@ -244,6 +244,29 @@ describe("Codex app-server provider (integration)", () => {
}
});
test("maps patch notifications with file_path aliases in array-style changes", () => {
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
callId: "patch-array-file-path",
changes: [
{
file_path: "/tmp/repo/src/alias-path.ts",
type: "modify",
diff: "@@\n-before\n+after\n",
},
],
cwd: "/tmp/repo",
running: false,
});
expect(item.detail.type).toBe("edit");
if (item.detail.type === "edit") {
expect(item.detail.filePath).toBe("src/alias-path.ts");
expect(item.detail.unifiedDiff).toContain("-before");
expect(item.detail.unifiedDiff).toContain("+after");
expect(item.detail.newString).toBeUndefined();
}
});
test.runIf(isCodexInstalled())("listModels returns live Codex models", async () => {
const client = new CodexAppServerAgentClient(logger);
const models = await client.listModels();
@@ -457,6 +480,15 @@ describe("Codex app-server provider (integration)", () => {
modeId: "read-only",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
extra: {
codex: {
tools: {
shell: false,
list_mcp_resources: false,
list_mcp_resource_templates: false,
},
},
},
mcpServers: {
paseo_test: {
type: "stdio",
@@ -468,7 +500,7 @@ describe("Codex app-server provider (integration)", () => {
const result = await session.run(
[
"You must call the MCP tool named paseo_test.echo exactly once.",
"You must call the MCP tool named paseo_test.paseo_roundtrip_text exactly once.",
`Call it with text: ${token}`,
"Do not use shell or any non-MCP tools.",
"After the tool call, respond with exactly the tool output text.",
@@ -481,9 +513,10 @@ describe("Codex app-server provider (integration)", () => {
item.type === "tool_call"
);
const toolNames = toolCalls.map((item) => item.name);
const nonMcpToolNames = toolNames.filter((name) => name !== "paseo_test.paseo_roundtrip_text");
const distinctMcpCalls = new Map<string, Extract<AgentTimelineItem, { type: "tool_call" }>>();
for (const call of toolCalls) {
if (call.name !== "paseo_test.echo") {
if (call.name !== "paseo_test.paseo_roundtrip_text") {
continue;
}
const key = String(call.callId ?? `${call.name}:${JSON.stringify(call.detail)}`);
@@ -494,14 +527,25 @@ describe("Codex app-server provider (integration)", () => {
}
// Hard assertion: exactly one distinct call of the exact MCP tool.
expect(toolNames.every((name) => name === "paseo_test.echo")).toBe(true);
if (nonMcpToolNames.length > 0) {
const nonMcpCalls = toolCalls
.filter((call) => call.name !== "paseo_test.paseo_roundtrip_text")
.map((call) => ({
name: call.name,
status: call.status,
detail: call.detail,
}));
throw new Error(
`Unexpected non-MCP tool calls in MCP round-trip: ${JSON.stringify(nonMcpCalls)}; all tool names: ${JSON.stringify(toolNames)}`
);
}
expect(distinctMcpCalls.size).toBe(1);
const mcpToolCall = Array.from(distinctMcpCalls.values())[0]!;
expect(mcpToolCall.name).toBe("paseo_test.echo");
expect(mcpToolCall.name).toBe("paseo_test.paseo_roundtrip_text");
expect(mcpToolCall.status).toBe("completed");
// Hard assertion: no non-MCP tools in this run.
expect(toolNames.every((name) => name === "paseo_test.echo")).toBe(true);
expect(nonMcpToolNames).toEqual([]);
expect(toolNames.some((name) => name.toLowerCase().includes("shell"))).toBe(false);
// Hard assertion: roundtrip token must be present in the MCP tool I/O.
@@ -521,7 +565,8 @@ describe("Codex app-server provider (integration)", () => {
const cleanup = useTempCodexSessionDir();
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
const promptsDir = path.join(codexHome, "prompts");
const promptPath = path.join(promptsDir, "test.md");
const promptName = `paseo-test-${process.pid}-${Date.now().toString(36)}`;
const promptPath = path.join(promptsDir, `${promptName}.md`);
const cwd = tmpCwd("codex-cmd-");
const token = `PASEO_PROMPT_TOKEN_${Date.now()}`;
@@ -549,13 +594,37 @@ describe("Codex app-server provider (integration)", () => {
});
try {
const commands = await session.listCommands?.();
expect(commands?.some((cmd) => cmd.name === "prompts:test")).toBe(true);
expect(commands?.some((cmd) => cmd.name === `prompts:${promptName}`)).toBe(true);
const executeArgs = "NAME=world extra_value";
const expectedExpanded = `${token}::name=world::pos1=extra_value::dollar=$`;
const rawSlashInput = "/prompts:test NAME=world extra_value";
const rawSlashInput = `/prompts:${promptName} ${executeArgs}`;
const runResult = await session.run(rawSlashInput);
expect(runResult.finalText).toContain(expectedExpanded);
const internal = session as unknown as {
client?: {
request: (method: string, params: unknown) => Promise<unknown>;
};
currentThreadId?: string | null;
};
const threadId = internal.currentThreadId;
const codexClient = internal.client;
if (!threadId || !codexClient) {
throw new Error("Codex session did not initialize app-server client/thread");
}
const threadRead = (await codexClient.request("thread/read", {
threadId,
includeTurns: true,
})) as { thread?: { path?: string } };
const rolloutPath = threadRead.thread?.path;
if (!rolloutPath) {
throw new Error("Codex app-server did not return rollout path");
}
const rolloutText = readFileSync(rolloutPath, "utf8");
expect(rolloutText).toContain(expectedExpanded);
} finally {
await session.close();
}
@@ -574,7 +643,8 @@ describe("Codex app-server provider (integration)", () => {
const cleanup = useTempCodexSessionDir();
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
const promptsDir = path.join(codexHome, "prompts");
const promptPath = path.join(promptsDir, "stream-test.md");
const promptName = `paseo-stream-${process.pid}-${Date.now().toString(36)}`;
const promptPath = path.join(promptsDir, `${promptName}.md`);
const cwd = tmpCwd("codex-cmd-stream-");
const token = `PASEO_STREAM_TOKEN_${Date.now()}`;
@@ -600,7 +670,7 @@ describe("Codex app-server provider (integration)", () => {
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
try {
const events = session.stream("/prompts:stream-test");
const events = session.stream(`/prompts:${promptName}`);
const seenTypes = new Set<string>();
const assistantChunks: string[] = [];
for await (const event of events) {
@@ -774,12 +844,17 @@ describe("Codex app-server provider (integration)", () => {
expect(sawPermission).toBe(true);
expect(sawPermissionResolvedDeny).toBe(true);
expect(
timelineItems.some(
expect(captured).not.toBeNull();
const deniedShellCall = timelineItems.find(
(item) =>
item.status === "failed" && hasShellCommand(item, "permission-deny.txt")
)
).toBe(true);
item.name === "shell" &&
item.status === "failed" &&
typeof item.metadata === "object" &&
item.metadata !== null &&
(item.metadata as { permissionRequestId?: string }).permissionRequestId === captured?.id
);
expect(deniedShellCall).toBeDefined();
expect(deniedShellCall ? hasShellCommand(deniedShellCall, "permission-deny.txt") : false).toBe(true);
expect(existsSync(filePath)).toBe(true);
} finally {
cleanup();
@@ -796,16 +871,6 @@ describe("Codex app-server provider (integration)", () => {
const patchFile = path.join(cwd, "patch.txt");
try {
const client = new CodexAppServerAgentClient(logger);
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "full-access",
approvalPolicy: "on-request",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
let sawAssistantMessage = false;
let sawShellTool = false;
let sawPatchTool = false;
@@ -813,13 +878,23 @@ describe("Codex app-server provider (integration)", () => {
let sawPatchCompleted = false;
const timelineItems: AgentTimelineItem[] = [];
const shellEvents = session.stream(
const shellClient = new CodexAppServerAgentClient(logger);
const shellSession = await shellClient.createSession({
provider: "codex",
cwd,
modeId: "full-access",
approvalPolicy: "on-request",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
let failure: string | null = null;
try {
const shellEvents = shellSession.stream(
"Run the exact shell command `printf \"ok\" > shell.txt`. After it completes, reply SHELL_DONE."
);
let failure: string | null = null;
for await (const event of shellEvents) {
if (event.type === "permission_requested") {
await session.respondToPermission(event.request.id, { behavior: "allow" });
await shellSession.respondToPermission(event.request.id, { behavior: "allow" });
}
if (event.type === "timeline") {
timelineItems.push(event.item);
@@ -841,6 +916,9 @@ describe("Codex app-server provider (integration)", () => {
break;
}
}
} finally {
await shellSession.close();
}
if (failure) {
throw new Error(failure);
@@ -852,7 +930,17 @@ describe("Codex app-server provider (integration)", () => {
"+patched",
"*** End Patch",
].join("\n");
const patchEvents = session.stream(
const patchClient = new CodexAppServerAgentClient(logger);
const patchSession = await patchClient.createSession({
provider: "codex",
cwd,
modeId: "full-access",
approvalPolicy: "on-request",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
try {
const patchEvents = patchSession.stream(
buildStrictApplyPatchPrompt(patch, "PATCH_DONE", {
includePermissionStep: true,
})
@@ -860,7 +948,7 @@ describe("Codex app-server provider (integration)", () => {
for await (const event of patchEvents) {
if (event.type === "permission_requested") {
await session.respondToPermission(event.request.id, { behavior: "allow" });
await patchSession.respondToPermission(event.request.id, { behavior: "allow" });
}
if (event.type === "timeline") {
timelineItems.push(event.item);
@@ -882,8 +970,9 @@ describe("Codex app-server provider (integration)", () => {
break;
}
}
await session.close();
} finally {
await patchSession.close();
}
if (failure) {
throw new Error(failure);
@@ -1294,11 +1383,16 @@ describe("Codex app-server provider (integration)", () => {
return result.value;
};
// Interrupt should be observed quickly; don't allow this test to hang if the stream stalls.
// Keep polling until the hard deadline; first-run Codex startup can leave
// a quiet gap >10s before the shell tool call appears.
const hardDeadline = Date.now() + 45_000;
while (Date.now() < hardDeadline) {
const event = await nextEvent(10_000);
if (!event) break;
const remainingMs = hardDeadline - Date.now();
const pollWindowMs = Math.max(250, Math.min(10_000, remainingMs));
const event = await nextEvent(pollWindowMs);
if (!event) {
continue;
}
if (event.type === "permission_requested") {
await session.respondToPermission(event.request.id, { behavior: "allow" });
@@ -812,6 +812,20 @@ function normalizeCodexCommandValue(
}
function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
const resolvePathFromRecord = (record: Record<string, unknown>): string => {
const directPath =
(typeof record.path === "string" && record.path.trim().length > 0
? record.path.trim()
: "") ||
(typeof record.file_path === "string" && record.file_path.trim().length > 0
? record.file_path.trim()
: "") ||
(typeof record.filePath === "string" && record.filePath.trim().length > 0
? record.filePath.trim()
: "");
return directPath;
};
if (!changes || typeof changes !== "object") {
return [];
}
@@ -823,10 +837,7 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
return null;
}
const record = entry as Record<string, unknown>;
const pathValue =
typeof record.path === "string" && record.path.trim().length > 0
? record.path.trim()
: "";
const pathValue = resolvePathFromRecord(record);
if (!pathValue) {
return null;
}
@@ -843,10 +854,11 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
}
const recordChanges = changes as Record<string, unknown>;
if (typeof recordChanges.path === "string" && recordChanges.path.trim().length > 0) {
const directPathValue = resolvePathFromRecord(recordChanges);
if (directPathValue) {
return [
{
path: recordChanges.path.trim(),
path: directPathValue,
kind:
(typeof recordChanges.kind === "string" && recordChanges.kind) ||
(typeof recordChanges.type === "string" && recordChanges.type) ||
@@ -2188,7 +2200,22 @@ class CodexAppServerAgentSession implements AgentSession {
await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
let sawTurnStarted = false;
for await (const event of queue) {
// Drop pre-start timeline noise that can leak from the previous turn.
// Keep permission events, which can legitimately arrive before turn_started.
if (!sawTurnStarted) {
if (event.type === "permission_requested" || event.type === "permission_resolved") {
yield event;
continue;
}
if (event.type === "turn_started") {
sawTurnStarted = true;
} else {
continue;
}
}
yield event;
if (
event.type === "turn_completed" ||
@@ -244,6 +244,57 @@ describe("codex tool-call mapper", () => {
}
});
it("maps fileChange object-style change payloads keyed by path", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-content-object-map",
status: "completed",
changes: {
"/tmp/repo/src/object-map.ts": {
type: "modify",
unified_diff: "@@\n-old\n+new\n",
},
},
},
{ cwd: "/tmp/repo" }
);
expect(item).toBeTruthy();
expect(item?.detail?.type).toBe("edit");
if (item?.detail?.type === "edit") {
expect(item.detail.filePath).toBe("src/object-map.ts");
expect(item.detail.unifiedDiff).toContain("-old");
expect(item.detail.unifiedDiff).toContain("+new");
}
});
it("maps fileChange array payloads that use file_path aliases", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-content-file-path-alias",
status: "completed",
changes: [
{
file_path: "/tmp/repo/src/file-path-alias.ts",
kind: "modify",
patch: "@@\n-before\n+after\n",
},
],
},
{ cwd: "/tmp/repo" }
);
expect(item).toBeTruthy();
expect(item?.detail?.type).toBe("edit");
if (item?.detail?.type === "edit") {
expect(item.detail.filePath).toBe("src/file-path-alias.ts");
expect(item.detail.unifiedDiff).toContain("-before");
expect(item.detail.unifiedDiff).toContain("+after");
}
});
it("maps write/edit/search known variants with distinct detail types", () => {
const writeItem = mapCodexToolCallFromThreadItem(
{
@@ -210,19 +210,7 @@ const CodexFileChangeItemSchema = z
id: z.string().min(1),
status: z.string().optional(),
error: z.unknown().optional(),
changes: z
.array(
z
.object({
path: z.string().optional(),
kind: z.string().optional(),
diff: z.string().optional(),
patch: z.string().optional(),
content: z.string().optional(),
})
.passthrough()
)
.optional(),
changes: z.unknown().optional(),
})
.passthrough();
@@ -628,30 +616,122 @@ function mapCommandExecutionItem(
};
}
type CodexFileChangeEntry = {
path: string;
kind?: string;
diff?: string;
};
function parseFileChangePath(
entry: Record<string, unknown>,
options?: CodexMapperOptions,
fallbackPath?: string
): string | undefined {
const rawPath =
(typeof entry.path === "string" && entry.path.trim().length > 0
? entry.path.trim()
: undefined) ??
(typeof entry.file_path === "string" && entry.file_path.trim().length > 0
? entry.file_path.trim()
: undefined) ??
(typeof entry.filePath === "string" && entry.filePath.trim().length > 0
? entry.filePath.trim()
: undefined) ??
(typeof fallbackPath === "string" && fallbackPath.trim().length > 0
? fallbackPath.trim()
: undefined);
if (!rawPath) {
return undefined;
}
return normalizeCodexFilePath(rawPath, options?.cwd);
}
function parseFileChangeKind(entry: Record<string, unknown>): string | undefined {
return (
(typeof entry.kind === "string" && entry.kind) ||
(typeof entry.type === "string" && entry.type) ||
undefined
);
}
function parseFileChangeDiff(entry: Record<string, unknown>): string | undefined {
return pickFirstPatchLikeString([
entry.diff,
entry.patch,
entry.unified_diff,
entry.unifiedDiff,
entry.content,
entry.newString,
]);
}
function toFileChangeEntry(
entry: Record<string, unknown>,
options?: CodexMapperOptions,
fallbackPath?: string
): CodexFileChangeEntry | null {
const path = parseFileChangePath(entry, options, fallbackPath);
if (!path) {
return null;
}
return {
path,
kind: parseFileChangeKind(entry),
diff: parseFileChangeDiff(entry),
};
}
function parseFileChangeEntries(
changes: unknown,
options?: CodexMapperOptions
): CodexFileChangeEntry[] {
if (!changes) {
return [];
}
if (Array.isArray(changes)) {
return changes
.map((entry) =>
isRecord(entry) ? toFileChangeEntry(entry, options) : null
)
.filter((entry): entry is CodexFileChangeEntry => entry !== null);
}
if (!isRecord(changes)) {
return [];
}
if (Array.isArray(changes.files)) {
return parseFileChangeEntries(changes.files, options);
}
const singleEntry = toFileChangeEntry(changes, options);
if (singleEntry) {
return [singleEntry];
}
return Object.entries(changes)
.map(([path, value]) => {
if (isRecord(value)) {
return toFileChangeEntry(value, options, path);
}
if (typeof value === "string") {
const normalizedPath = normalizeCodexFilePath(path.trim(), options?.cwd);
if (!normalizedPath) {
return null;
}
return { path: normalizedPath, diff: value };
}
return null;
})
.filter((entry): entry is CodexFileChangeEntry => entry !== null);
}
function mapFileChangeItem(
item: z.infer<typeof CodexFileChangeItemSchema>,
options?: CodexMapperOptions
): CodexNormalizedToolCallEnvelope {
const changes = item.changes ?? [];
const files = changes
.map((change) => {
const pathValue =
typeof change.path === "string"
? normalizeCodexFilePath(change.path.trim(), options?.cwd)
: undefined;
return {
path: pathValue,
kind: change.kind,
diff: pickFirstPatchLikeString([
change.diff,
change.patch,
change.content,
]),
};
})
.filter((change) => change.path !== undefined);
const files = parseFileChangeEntries(item.changes, options);
const inputBase = {
...(files.length > 0
+44 -4
View File
@@ -99,6 +99,19 @@ export type PaseoSpeechConfig = {
local?: PaseoLocalSpeechConfig;
};
export type DaemonLifecycleIntent =
| {
type: "shutdown";
clientId: string;
requestId: string;
}
| {
type: "restart";
clientId: string;
requestId: string;
reason?: string;
};
export type PaseoDaemonConfig = {
listen: string;
paseoHome: string;
@@ -121,6 +134,11 @@ export type PaseoDaemonConfig = {
dictationFinalTimeoutMs?: number;
downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
pidLock?: {
mode?: "self" | "external";
ownerPid?: number;
};
};
export interface PaseoDaemon {
@@ -138,9 +156,16 @@ export async function createPaseoDaemon(
): Promise<PaseoDaemon> {
const logger = rootLogger.child({ module: "bootstrap" });
const daemonVersion = resolveDaemonVersion(import.meta.url);
const pidLockMode = config.pidLock?.mode ?? "self";
const pidLockOwnerPid = config.pidLock?.ownerPid;
const ownsPidLock = pidLockMode === "self";
// Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately.
await acquirePidLock(config.paseoHome, config.listen);
if (ownsPidLock) {
await acquirePidLock(config.paseoHome, config.listen, {
ownerPid: pidLockOwnerPid,
});
}
try {
const serverId = getOrCreateServerId(config.paseoHome, { logger });
@@ -499,7 +524,14 @@ export async function createPaseoDaemon(
getSpeechReadiness,
},
config.agentProviderSettings,
daemonVersion
daemonVersion,
(intent) => {
try {
config.onLifecycleIntent?.(intent);
} catch (error) {
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
}
}
);
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
wsServer?.publishSpeechReadiness(snapshot);
@@ -601,7 +633,11 @@ export async function createPaseoDaemon(
unlinkSync(listenTarget.path);
}
// Release PID lock
await releasePidLock(config.paseoHome);
if (ownsPidLock) {
await releasePidLock(config.paseoHome, {
ownerPid: pidLockOwnerPid,
});
}
};
return {
@@ -613,7 +649,11 @@ export async function createPaseoDaemon(
stop,
};
} catch (err) {
await releasePidLock(config.paseoHome).catch(() => undefined);
if (ownsPidLock) {
await releasePidLock(config.paseoHome, {
ownerPid: pidLockOwnerPid,
}).catch(() => undefined);
}
throw err;
}
}
@@ -11,6 +11,7 @@ import {
} from "./test-utils/paseo-daemon.js";
import { DaemonClient } from "./test-utils/daemon-client.js";
import type { AgentStreamEventPayload } from "../shared/messages.js";
import type { AgentSnapshotPayload } from "./messages.js";
/**
* Tests for client activity tracking and smart notifications.
@@ -32,6 +33,9 @@ import type { AgentStreamEventPayload } from "../shared/messages.js";
* - appVisible: whether the app/tab is in foreground
*/
describe("client activity tracking", () => {
const TEST_PROVIDER = "claude";
const TEST_MODEL = "claude-haiku-4-5";
const TEST_CWD = "/tmp";
let daemon: TestPaseoDaemon;
let client1: DaemonClient;
let client2: DaemonClient;
@@ -55,6 +59,19 @@ describe("client activity tracking", () => {
return client;
}
async function createUiAgent(params: {
client: DaemonClient;
title: string;
}): Promise<AgentSnapshotPayload> {
return params.client.createAgent({
provider: TEST_PROVIDER,
model: TEST_MODEL,
cwd: TEST_CWD,
title: params.title,
labels: { ui: "true" },
});
}
function waitForAttentionRequired(
client: DaemonClient,
agentId: string,
@@ -86,10 +103,8 @@ describe("client activity tracking", () => {
test("no notification when actively focused on agent", async () => {
client1 = await createClient();
const agent = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
const agent = await createUiAgent({
client: client1,
title: "Active Focus Test",
});
@@ -114,19 +129,9 @@ describe("client activity tracking", () => {
test("notification when focused on different agent", async () => {
client1 = await createClient();
const agent1 = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Agent 1",
});
const agent1 = await createUiAgent({ client: client1, title: "Agent 1" });
const agent2 = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Agent 2",
});
const agent2 = await createUiAgent({ client: client1, title: "Agent 2" });
// User is looking at agent2, not agent1
client1.sendHeartbeat({
@@ -150,10 +155,8 @@ describe("client activity tracking", () => {
test("no notification when app is not visible but activity is recent (user just switched tabs)", async () => {
client1 = await createClient();
const agent = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
const agent = await createUiAgent({
client: client1,
title: "App Hidden Test",
});
@@ -179,10 +182,8 @@ describe("client activity tracking", () => {
test("notification when activity is stale (user walked away for 2+ minutes)", async () => {
client1 = await createClient();
const agent = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
const agent = await createUiAgent({
client: client1,
title: "Stale Activity Test",
});
@@ -209,10 +210,8 @@ describe("client activity tracking", () => {
test("notification when no heartbeat received (legacy/new client)", async () => {
client1 = await createClient();
const agent = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
const agent = await createUiAgent({
client: client1,
title: "No Heartbeat Test",
});
@@ -237,10 +236,8 @@ describe("client activity tracking", () => {
client1 = await createClient();
client2 = await createClient();
const agent = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
const agent = await createUiAgent({
client: client1,
title: "Two Tabs Test",
});
@@ -280,10 +277,8 @@ describe("client activity tracking", () => {
client1 = await createClient();
client2 = await createClient();
const agent = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
const agent = await createUiAgent({
client: client1,
title: "Both Inactive Test",
});
@@ -330,10 +325,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web
client2 = await createClient(); // mobile
const agent = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
const agent = await createUiAgent({
client: client1,
title: "Web Active Test",
});
@@ -373,10 +366,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web
client2 = await createClient(); // mobile
const agent = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
const agent = await createUiAgent({
client: client1,
title: "Mobile Active Test",
});
@@ -416,10 +407,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web
client2 = await createClient(); // mobile
const agent = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
const agent = await createUiAgent({
client: client1,
title: "Web Stale Test",
});
@@ -459,19 +448,9 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web
client2 = await createClient(); // mobile
const agent1 = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Agent 1",
});
const agent1 = await createUiAgent({ client: client1, title: "Agent 1" });
const agent2 = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Agent 2",
});
const agent2 = await createUiAgent({ client: client1, title: "Agent 2" });
// Web: active but looking at agent2
client1.sendHeartbeat({
@@ -510,10 +489,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web
client2 = await createClient(); // mobile
const agent = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
const agent = await createUiAgent({
client: client1,
title: "Both Inactive Test",
});
@@ -562,10 +539,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web
client2 = await createClient(); // mobile - no heartbeat
const agent = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
const agent = await createUiAgent({
client: client1,
title: "Mobile No Heartbeat Test",
});
@@ -600,10 +575,8 @@ describe("client activity tracking", () => {
test("no notification when app not visible but activity recent (switched tabs recently)", async () => {
client1 = await createClient();
const agent = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
const agent = await createUiAgent({
client: client1,
title: "Tab Switch Test",
});
@@ -630,10 +603,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web
client2 = await createClient(); // mobile
const agent = await client1.createAgent({
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
const agent = await createUiAgent({
client: client1,
title: "Both Recent Activity Test",
});
@@ -244,7 +244,9 @@ describe("daemon checkout ship loop", () => {
expect(existsSync(worktree.worktreePath)).toBe(false);
const remainingAgents = await ctx.client.fetchAgents();
expect(remainingAgents.some((entry) => entry.id === agent.id)).toBe(false);
expect(
remainingAgents.entries.some((entry) => entry.agent.id === agent.id)
).toBe(false);
} finally {
if (agentId) {
await ctx.client.deleteAgent(agentId).catch(() => undefined);
@@ -1,27 +1,6 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
import { describe, test, expect } from "vitest";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("multi-agent orchestration", () => {
// TODO: Re-implement orchestration tests with new Paseo MCP
// The old agent-control MCP has been removed
@@ -377,7 +377,8 @@ const shouldRun = !process.env.CI;
}
});
ctx.client.sendTerminalStreamKey(streamId, { key: "d", ctrl: true });
const kill = await ctx.client.killTerminal(terminalId);
expect(kill.success).toBe(true);
await waitForCondition(() => sawExit, 10000);
@@ -457,6 +458,60 @@ const shouldRun = !process.env.CI;
ws.once("error", reject);
});
const helloReady = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("Timed out waiting for websocket welcome"));
}, 10000);
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onMessage = (raw: WebSocket.RawData) => {
if (Array.isArray(raw)) {
raw = Buffer.concat(
raw.map((part) => (Buffer.isBuffer(part) ? part : Buffer.from(part)))
);
}
if (typeof raw !== "string" && !Buffer.isBuffer(raw)) {
return;
}
const text = typeof raw === "string" ? raw : raw.toString("utf8");
try {
const parsed = JSON.parse(text) as { type?: string };
if (parsed.type === "welcome") {
cleanup();
resolve();
}
} catch {
// Ignore non-JSON payloads (binary mux frames).
}
};
const cleanup = () => {
clearTimeout(timeout);
ws.off("message", onMessage);
ws.off("error", onError);
};
ws.on("message", onMessage);
ws.on("error", onError);
});
ws.send(
JSON.stringify({
type: "hello",
clientId: `terminal-backpressure-${Date.now()}-${Math.random()
.toString(36)
.slice(2)}`,
clientType: "cli",
protocolVersion: 1,
})
);
await helloReady;
const attachRequestId = `attach-${Date.now()}`;
const detachRequestId = `detach-${Date.now()}`;
let streamId: number | null = null;
@@ -546,7 +601,7 @@ const shouldRun = !process.env.CI;
terminalId,
message: {
type: "input",
data: "head -c 1048576 /dev/zero | tr '\\0' 'A'\r",
data: "head -c 8388608 /dev/zero | tr '\\0' 'A'\r",
},
},
})
+115 -30
View File
@@ -4,11 +4,24 @@ import { resolvePaseoHome } from "./paseo-home.js";
import { createRootLogger } from "./logger.js";
import { loadPersistedConfig } from "./persisted-config.js";
import { PidLockError } from "./pid-lock.js";
import type { DaemonLifecycleIntent } from "./bootstrap.js";
type SupervisorLifecycleMessage =
| {
type: "paseo:shutdown";
}
| {
type: "paseo:restart";
reason?: string;
};
async function main() {
let paseoHome: string;
let logger: ReturnType<typeof createRootLogger>;
let config: ReturnType<typeof loadConfig>;
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
let shutdownPromise: Promise<number> | null = null;
let exitHookInstalled = false;
try {
paseoHome = resolvePaseoHome();
@@ -28,9 +41,107 @@ async function main() {
config.mcpEnabled = false;
}
let daemon;
const installExitHook = () => {
if (exitHookInstalled || !shutdownPromise) {
return;
}
exitHookInstalled = true;
void shutdownPromise.then((exitCode) => {
process.exit(exitCode);
});
};
const beginShutdown = (
signal: string,
options?: {
successExitCode?: number;
}
) => {
if (!shutdownPromise) {
logger.info(`${signal} received, shutting down gracefully...`);
shutdownPromise = (async () => {
const forceExit = setTimeout(() => {
logger.warn("Forcing shutdown - HTTP server didn't close in time");
process.exit(1);
}, 10000);
try {
daemon = await createPaseoDaemon(config, logger);
if (!daemon) {
logger.error("Shutdown requested before daemon initialization completed");
clearTimeout(forceExit);
return 1;
}
await daemon.stop();
clearTimeout(forceExit);
logger.info("Server closed");
return options?.successExitCode ?? 0;
} catch (err) {
clearTimeout(forceExit);
logger.error({ err }, "Shutdown failed");
return 1;
}
})();
} else {
logger.info(`${signal} received while shutdown is already in progress`);
}
installExitHook();
};
const sendSupervisorLifecycleMessage = (message: SupervisorLifecycleMessage): boolean => {
if (typeof process.send !== "function") {
return false;
}
try {
process.send(message);
return true;
} catch (err) {
logger.error({ err, message }, "Failed to send lifecycle IPC message to supervisor");
return false;
}
};
const handleLifecycleIntent = (intent: DaemonLifecycleIntent) => {
if (intent.type === "shutdown") {
logger.warn(
{ clientId: intent.clientId, requestId: intent.requestId },
"Shutdown requested via websocket"
);
if (sendSupervisorLifecycleMessage({ type: "paseo:shutdown" })) {
return;
}
beginShutdown("shutdown lifecycle intent");
return;
}
logger.warn(
{ clientId: intent.clientId, requestId: intent.requestId, reason: intent.reason },
"Restart requested via websocket"
);
if (
sendSupervisorLifecycleMessage({
type: "paseo:restart",
...(intent.reason ? { reason: intent.reason } : {}),
})
) {
return;
}
beginShutdown("restart lifecycle intent", { successExitCode: 0 });
};
try {
const pidLockMode = process.env.PASEO_PID_LOCK_MODE === "external" ? "external" : "self";
daemon = await createPaseoDaemon(
{
...config,
onLifecycleIntent: handleLifecycleIntent,
pidLock: {
mode: pidLockMode,
},
},
logger
);
} catch (err) {
if (err instanceof PidLockError) {
logger.error({ pid: err.existingLock?.pid }, err.message);
@@ -49,34 +160,8 @@ async function main() {
throw err;
}
let shuttingDown = false;
const handleShutdown = async (signal: string) => {
if (shuttingDown) {
logger.info("Forcing exit...");
process.exit(1);
}
shuttingDown = true;
logger.info(`${signal} received, shutting down gracefully... (press Ctrl+C again to force exit)`);
const forceExit = setTimeout(() => {
logger.warn("Forcing shutdown - HTTP server didn't close in time");
process.exit(1);
}, 10000);
try {
await daemon.stop();
clearTimeout(forceExit);
logger.info("Server closed");
process.exit(0);
} catch (err) {
clearTimeout(forceExit);
logger.error({ err }, "Shutdown failed");
process.exit(1);
}
};
process.on("SIGTERM", () => handleShutdown("SIGTERM"));
process.on("SIGINT", () => handleShutdown("SIGINT"));
process.on("SIGTERM", () => beginShutdown("SIGTERM"));
process.on("SIGINT", () => beginShutdown("SIGINT"));
}
main().catch((err) => {
@@ -0,0 +1,40 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, test } from 'vitest'
import { acquirePidLock, getPidLockInfo, releasePidLock } from './pid-lock.js'
describe('pid-lock ownership', () => {
test('writes and releases lock for explicit owner pid', async () => {
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-pid-lock-owner-'))
const ownerPid = process.pid + 10_000
try {
await (acquirePidLock as unknown as (home: string, sockPath: string, options: { ownerPid: number }) => Promise<void>)(
paseoHome,
'127.0.0.1:6767',
{ ownerPid }
)
const lock = await getPidLockInfo(paseoHome)
expect(lock?.pid).toBe(ownerPid)
await (releasePidLock as unknown as (home: string, options: { ownerPid: number }) => Promise<void>)(
paseoHome,
{ ownerPid: ownerPid + 1 }
)
const lockAfterWrongOwnerRelease = await getPidLockInfo(paseoHome)
expect(lockAfterWrongOwnerRelease?.pid).toBe(ownerPid)
await (releasePidLock as unknown as (home: string, options: { ownerPid: number }) => Promise<void>)(
paseoHome,
{ ownerPid }
)
const lockAfterOwnerRelease = await getPidLockInfo(paseoHome)
expect(lockAfterOwnerRelease).toBeNull()
} finally {
await rm(paseoHome, { recursive: true, force: true })
}
})
})
+11 -11
View File
@@ -34,20 +34,17 @@ function getPidFilePath(paseoHome: string): string {
return join(paseoHome, "paseo.pid");
}
function resolveLockOwnerPid(): number {
if (typeof process.send === "function") {
const ppid = process.ppid;
if (Number.isInteger(ppid) && ppid > 1) {
return ppid;
function resolveOwnerPid(ownerPid?: number): number {
if (typeof ownerPid === "number" && Number.isInteger(ownerPid) && ownerPid > 0) {
return ownerPid;
}
}
return process.pid;
}
export async function acquirePidLock(
paseoHome: string,
sockPath: string
sockPath: string,
options?: { ownerPid?: number }
): Promise<void> {
const pidPath = getPidFilePath(paseoHome);
@@ -66,7 +63,7 @@ export async function acquirePidLock(
}
// Check if existing lock is stale
const lockOwnerPid = resolveLockOwnerPid();
const lockOwnerPid = resolveOwnerPid(options?.ownerPid);
if (existingLock) {
if (isPidRunning(existingLock.pid)) {
if (existingLock.pid === lockOwnerPid) {
@@ -117,9 +114,12 @@ export async function acquirePidLock(
}
}
export async function releasePidLock(paseoHome: string): Promise<void> {
export async function releasePidLock(
paseoHome: string,
options?: { ownerPid?: number }
): Promise<void> {
const pidPath = getPidFilePath(paseoHome);
const lockOwnerPid = resolveLockOwnerPid();
const lockOwnerPid = resolveOwnerPid(options?.ownerPid);
try {
// Only remove if it's our lock
const content = await readFile(pidPath, "utf-8");
@@ -324,6 +324,7 @@ export function startRelayTransport({
attached = true;
const externalMetadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: `session:${connectionId}`,
};
if (daemonKeyPair) {
void attachEncryptedSocket(
@@ -0,0 +1,9 @@
import { readFileSync } from 'node:fs'
import { describe, expect, test } from 'vitest'
describe('session lifecycle boundary', () => {
test('does not perform process lifecycle side effects directly', () => {
const source = readFileSync(new URL('./session.ts', import.meta.url), 'utf8')
expect(source).not.toMatch(/process\.(exit|send|kill)\s*\(/)
})
})
+91 -69
View File
@@ -146,9 +146,7 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
GIT_OPTIONAL_LOCKS: '0',
}
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>()
let restartRequested = false
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]
const RESTART_EXIT_DELAY_MS = 250
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000
const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024
@@ -346,6 +344,7 @@ export type SessionOptions = {
clientId: string
onMessage: (msg: SessionOutboundMessage) => void
onBinaryMessage?: (frame: BinaryMuxFrame) => void
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void
logger: pino.Logger
downloadTokenStore: DownloadTokenStore
pushTokenStore: PushTokenStore
@@ -379,6 +378,19 @@ export type SessionOptions = {
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap
}
export type SessionLifecycleIntent =
| {
type: 'shutdown'
clientId: string
requestId: string
}
| {
type: 'restart'
clientId: string
requestId: string
reason?: string
}
type VoiceFeatureUnavailableContext = {
reasonCode: SpeechReadinessSnapshot['voiceFeature']['reasonCode']
message: string
@@ -480,6 +492,7 @@ export class Session {
private readonly sessionId: string
private readonly onMessage: (msg: SessionOutboundMessage) => void
private readonly onBinaryMessage: ((frame: BinaryMuxFrame) => void) | null
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null
private readonly sessionLogger: pino.Logger
private readonly paseoHome: string
@@ -581,6 +594,7 @@ export class Session {
clientId,
onMessage,
onBinaryMessage,
onLifecycleIntent,
logger,
downloadTokenStore,
pushTokenStore,
@@ -600,6 +614,7 @@ export class Session {
this.sessionId = uuidv4()
this.onMessage = onMessage
this.onBinaryMessage = onBinaryMessage ?? null
this.onLifecycleIntent = onLifecycleIntent ?? null
this.downloadTokenStore = downloadTokenStore
this.pushTokenStore = pushTokenStore
this.paseoHome = paseoHome
@@ -717,10 +732,6 @@ export class Session {
}
if (snapshot.lifecycle !== 'running' && !snapshot.pendingRun) {
this.sessionLogger.debug(
{ agentId, lifecycle: snapshot.lifecycle, pendingRun: Boolean(snapshot.pendingRun) },
'interruptAgentIfRunning: not running, skipping'
)
return
}
@@ -768,8 +779,6 @@ export class Session {
prompt: AgentPromptInput,
runOptions?: AgentRunOptions
): { ok: true } | { ok: false; error: string } {
this.sessionLogger.info({ agentId }, `Starting agent stream for ${agentId}`)
let iterator: AsyncGenerator<AgentStreamEvent>
try {
iterator = this.agentManager.streamAgent(agentId, prompt, runOptions)
@@ -1360,6 +1369,10 @@ export class Session {
await this.handleRestartServerRequest(msg.requestId, msg.reason)
break
case 'shutdown_server_request':
await this.handleShutdownServerRequest(msg.requestId)
break
case 'fetch_agent_timeline_request':
await this.handleFetchAgentTimelineRequest(msg)
break
@@ -1634,12 +1647,6 @@ export class Session {
}
private async handleRestartServerRequest(requestId: string, reason?: string): Promise<void> {
if (restartRequested) {
this.sessionLogger.debug('Restart already requested, ignoring duplicate')
return
}
restartRequested = true
const payload: { status: string } & Record<string, unknown> = {
status: 'restart_requested',
clientId: this.clientId,
@@ -1655,17 +1662,41 @@ export class Session {
payload,
})
if (typeof process.send === 'function') {
process.send({
type: 'paseo:restart',
this.emitLifecycleIntent({
type: 'restart',
clientId: this.clientId,
requestId,
...(reason ? { reason } : {}),
})
return
}
setTimeout(() => {
process.exit(0)
}, RESTART_EXIT_DELAY_MS)
private async handleShutdownServerRequest(requestId: string): Promise<void> {
this.sessionLogger.warn('Shutdown requested via websocket')
this.emit({
type: 'status',
payload: {
status: 'shutdown_requested',
clientId: this.clientId,
requestId,
},
})
this.emitLifecycleIntent({
type: 'shutdown',
clientId: this.clientId,
requestId,
})
}
private emitLifecycleIntent(intent: SessionLifecycleIntent): void {
if (!this.onLifecycleIntent) {
return
}
try {
this.onLifecycleIntent(intent)
} catch (error) {
this.sessionLogger.error({ err: error, intent }, 'Lifecycle intent handler failed')
}
}
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
@@ -2426,43 +2457,6 @@ export class Session {
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { labels })
await this.forwardAgentUpdate(snapshot)
const trimmedPrompt = initialPrompt?.trim()
if (trimmedPrompt) {
scheduleAgentMetadataGeneration({
agentManager: this.agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
initialPrompt: trimmedPrompt,
explicitTitle: snapshot.config.title,
paseoHome: this.paseoHome,
logger: this.sessionLogger,
})
try {
await this.handleSendAgentMessage(
snapshot.id,
trimmedPrompt,
resolveClientMessageId(clientMessageId),
images,
outputSchema ? { outputSchema } : undefined
)
} catch (promptError) {
this.sessionLogger.error(
{ err: promptError, agentId: snapshot.id },
`Failed to run initial prompt for agent ${snapshot.id}`
)
this.emit({
type: 'activity_log',
payload: {
id: uuidv4(),
timestamp: new Date(),
type: 'error',
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
},
})
}
}
if (requestId) {
const agentPayload = await this.getAgentPayloadById(snapshot.id)
if (!agentPayload) {
@@ -2479,6 +2473,41 @@ export class Session {
})
}
const trimmedPrompt = initialPrompt?.trim()
if (trimmedPrompt) {
scheduleAgentMetadataGeneration({
agentManager: this.agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
initialPrompt: trimmedPrompt,
explicitTitle: snapshot.config.title,
paseoHome: this.paseoHome,
logger: this.sessionLogger,
})
void this.handleSendAgentMessage(
snapshot.id,
trimmedPrompt,
resolveClientMessageId(clientMessageId),
images,
outputSchema ? { outputSchema } : undefined
).catch((promptError) => {
this.sessionLogger.error(
{ err: promptError, agentId: snapshot.id },
`Failed to run initial prompt for agent ${snapshot.id}`
)
this.emit({
type: 'activity_log',
payload: {
id: uuidv4(),
timestamp: new Date(),
type: 'error',
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
},
})
})
}
if (worktreeConfig) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
@@ -3336,10 +3365,6 @@ export class Session {
*/
private async handleClearAgentAttention(agentId: string | string[]): Promise<void> {
const agentIds = Array.isArray(agentId) ? agentId : [agentId]
this.sessionLogger.debug(
{ agentIds },
`Clearing attention for ${agentIds.length} agent(s): ${agentIds.join(', ')}`
)
try {
await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id)))
@@ -4580,11 +4605,6 @@ export class Session {
private async handleFileExplorerRequest(request: FileExplorerRequest): Promise<void> {
const { agentId, path: requestedPath = '.', mode, requestId } = request
this.sessionLogger.debug(
{ agentId, mode, path: requestedPath },
`Handling file explorer request for agent ${agentId} (${mode} ${requestedPath})`
)
try {
const agents = this.agentManager.listAgents()
const agent = agents.find((a) => a.id === agentId)
@@ -6211,7 +6231,6 @@ export class Session {
if (this.agentMcpClient) {
try {
await this.agentMcpClient.close()
this.sessionLogger.debug('Agent MCP client closed')
} catch (error) {
this.sessionLogger.error({ err: error }, 'Failed to close Agent MCP client')
}
@@ -6639,7 +6658,10 @@ export class Session {
const existingStreamId = this.terminalStreamByTerminalId.get(msg.terminalId)
if (typeof existingStreamId === 'number') {
this.detachTerminalStream(existingStreamId, { emitExit: false })
// Replacing an active stream can happen when multiple UI surfaces attach to the
// same terminal. Emit exit for the replaced stream so stale listeners reconnect
// instead of continuing to send input to an invalid stream id.
this.detachTerminalStream(existingStreamId, { emitExit: true })
}
const streamId = this.allocateTerminalStreamId()
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { TerminalManager } from "./terminal-manager.js";
import { findSessionByName, killSession } from "./tmux.js";
const TEST_SESSION = "test-terminal-manager";
const TEST_SESSION = `test-terminal-manager-${process.pid}-${Date.now().toString(36)}`;
const ANSI_ESCAPE_REGEX = /\u001B[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
@@ -11,6 +11,39 @@ function isIgnorableCleanupError(error: unknown): boolean {
return code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM";
}
const NO_BASE_CONFIG_DIR = Symbol("no-base-config-dir");
let baseConfigDir: string | typeof NO_BASE_CONFIG_DIR = NO_BASE_CONFIG_DIR;
const activeConfigDirs: string[] = [];
function activateClaudeConfigDir(configDir: string): void {
if (activeConfigDirs.length === 0) {
baseConfigDir =
typeof process.env.CLAUDE_CONFIG_DIR === "string"
? process.env.CLAUDE_CONFIG_DIR
: NO_BASE_CONFIG_DIR;
}
activeConfigDirs.push(configDir);
process.env.CLAUDE_CONFIG_DIR = configDir;
}
function deactivateClaudeConfigDir(configDir: string): void {
const index = activeConfigDirs.lastIndexOf(configDir);
if (index !== -1) {
activeConfigDirs.splice(index, 1);
}
const latestActiveDir = activeConfigDirs[activeConfigDirs.length - 1];
if (latestActiveDir) {
process.env.CLAUDE_CONFIG_DIR = latestActiveDir;
return;
}
if (baseConfigDir === NO_BASE_CONFIG_DIR) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = baseConfigDir;
}
baseConfigDir = NO_BASE_CONFIG_DIR;
}
/**
* Sets up an isolated Claude config directory for testing.
* Creates a temp directory with:
@@ -22,7 +55,6 @@ function isIgnorableCleanupError(error: unknown): boolean {
* Returns a cleanup function that restores the original env and removes the temp dir.
*/
export function useTempClaudeConfigDir(): () => void {
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
const configDir = mkdtempSync(path.join(tmpdir(), "claude-config-"));
const settings = {
permissions: {
@@ -40,13 +72,9 @@ export function useTempClaudeConfigDir(): () => void {
writeFileSync(path.join(configDir, "settings.json"), settingsText, "utf8");
writeFileSync(path.join(configDir, "settings.local.json"), settingsText, "utf8");
seedClaudeAuth(configDir);
process.env.CLAUDE_CONFIG_DIR = configDir;
activateClaudeConfigDir(configDir);
return () => {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
}
deactivateClaudeConfigDir(configDir);
try {
rmSync(configDir, { recursive: true, force: true });
} catch (error) {
@@ -51,10 +51,38 @@ async function getAvailablePort(): Promise<number> {
});
}
const TEST_DAEMON_START_TIMEOUT_MS = 20_000;
async function startDaemonWithTimeout(
daemon: Awaited<ReturnType<typeof createPaseoDaemon>>,
timeoutMs: number
): Promise<void> {
await new Promise<void>((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
const timeoutError = new Error(
`Timed out starting test daemon after ${timeoutMs}ms`
) as Error & { code?: string };
timeoutError.code = "TEST_DAEMON_START_TIMEOUT";
reject(timeoutError);
}, timeoutMs);
daemon.start().then(
() => {
clearTimeout(timeoutHandle);
resolve();
},
(error) => {
clearTimeout(timeoutHandle);
reject(error);
}
);
});
}
export async function createTestPaseoDaemon(
options: TestPaseoDaemonOptions = {}
): Promise<TestPaseoDaemon> {
const maxAttempts = 5;
const maxAttempts = 8;
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
@@ -91,7 +119,7 @@ export async function createTestPaseoDaemon(
const logger = options.logger ?? pino({ level: "silent" });
const daemon = await createPaseoDaemon(config, logger);
try {
await daemon.start();
await startDaemonWithTimeout(daemon, TEST_DAEMON_START_TIMEOUT_MS);
const close = async (): Promise<void> => {
await daemon.stop().catch(() => undefined);
@@ -117,7 +145,10 @@ export async function createTestPaseoDaemon(
await rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
if (!isAddressInUseError(error) || attempt === maxAttempts - 1) {
if (
(!isAddressInUseError(error) && !isStartupTimeoutError(error)) ||
attempt === maxAttempts - 1
) {
throw error;
}
}
@@ -133,3 +164,11 @@ function isAddressInUseError(error: unknown): boolean {
const record = error as { code?: string };
return record.code === "EADDRINUSE";
}
function isStartupTimeoutError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
const record = error as { code?: string };
return record.code === "TEST_DAEMON_START_TIMEOUT";
}
@@ -25,7 +25,7 @@ import {
} from "../shared/binary-mux.js";
import type { AllowedHostsConfig } from "./allowed-hosts.js";
import { isHostAllowed } from "./allowed-hosts.js";
import { Session } from "./session.js";
import { Session, type SessionLifecycleIntent } from "./session.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { PushTokenStore } from "./push/token-store.js";
@@ -53,6 +53,7 @@ import {
export type AgentMcpTransportFactory = () => Promise<Transport>;
export type ExternalSocketMetadata = {
transport: "relay";
externalSessionKey?: string;
};
type PendingConnection = {
@@ -216,6 +217,7 @@ export class VoiceAssistantWebSocketServer {
>();
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private serverCapabilities: ServerCapabilities | undefined;
constructor(
@@ -248,7 +250,8 @@ export class VoiceAssistantWebSocketServer {
getSpeechReadiness?: () => SpeechReadinessSnapshot;
},
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap,
daemonVersion?: string
daemonVersion?: string,
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -267,6 +270,7 @@ export class VoiceAssistantWebSocketServer {
this.voice = voice ?? null;
this.dictation = dictation ?? null;
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
this.onLifecycleIntent = onLifecycleIntent ?? null;
this.serverCapabilities = buildServerCapabilities({
readiness: this.dictation?.getSpeechReadiness?.() ?? null,
});
@@ -522,6 +526,9 @@ export class VoiceAssistantWebSocketServer {
}
this.sendBinaryToConnection(connection, frame);
},
onLifecycleIntent: (intent) => {
this.onLifecycleIntent?.(intent);
},
logger: connectionLogger.child({ module: "session" }),
downloadTokenStore: this.downloadTokenStore,
pushTokenStore: this.pushTokenStore,
@@ -45,6 +45,24 @@ describe('shared messages stream parsing', () => {
expect(parsed.payload.entries[0]?.item.type).toBe('assistant_message')
})
it('parses explicit shutdown and restart lifecycle request payloads as distinct message types', () => {
const shutdownParsed = SessionInboundMessageSchema.safeParse({
type: 'shutdown_server_request',
requestId: 'req-shutdown-1',
})
expect(shutdownParsed.success).toBe(true)
const restartParsed = SessionInboundMessageSchema.safeParse({
type: 'restart_server_request',
requestId: 'req-restart-1',
reason: 'settings_changed',
})
expect(restartParsed.success).toBe(true)
expect(shutdownParsed.success && shutdownParsed.data.type).toBe('shutdown_server_request')
expect(restartParsed.success && restartParsed.data.type).toBe('restart_server_request')
})
it('parses representative agent_stream tool_call event', () => {
const parsed = AgentStreamMessageSchema.parse({
type: 'agent_stream',
+14
View File
@@ -648,6 +648,11 @@ export const RestartServerRequestMessageSchema = z.object({
requestId: z.string(),
})
export const ShutdownServerRequestMessageSchema = z.object({
type: z.literal('shutdown_server_request'),
requestId: z.string(),
})
export const AgentTimelineCursorSchema = z.object({
epoch: z.string(),
seq: z.number().int().nonnegative(),
@@ -1080,6 +1085,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion('type', [
ResumeAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema,
CancelAgentRequestMessageSchema,
ShutdownServerRequestMessageSchema,
RestartServerRequestMessageSchema,
FetchAgentTimelineRequestMessageSchema,
SetAgentModeRequestMessageSchema,
@@ -1351,11 +1357,18 @@ export const RestartRequestedStatusPayloadSchema = z.object({
requestId: z.string(),
})
export const ShutdownRequestedStatusPayloadSchema = z.object({
status: z.literal('shutdown_requested'),
clientId: z.string(),
requestId: z.string(),
})
export const KnownStatusPayloadSchema = z.discriminatedUnion('status', [
AgentCreatedStatusPayloadSchema,
AgentCreateFailedStatusPayloadSchema,
AgentResumedStatusPayloadSchema,
AgentRefreshedStatusPayloadSchema,
ShutdownRequestedStatusPayloadSchema,
RestartRequestedStatusPayloadSchema,
])
@@ -2187,6 +2200,7 @@ export type ProjectIcon = z.infer<typeof ProjectIconSchema>
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>
export type ShutdownServerRequestMessage = z.infer<typeof ShutdownServerRequestMessageSchema>
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>
export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema>
export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>
+3 -2
View File
@@ -16,8 +16,9 @@ export default defineConfig({
pool: "forks",
poolOptions: {
forks: {
singleFork: false,
maxForks: 4,
singleFork: true,
minForks: 1,
maxForks: 1,
},
},
exclude: ["**/node_modules/**", "**/dist/**"],