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 createAgent(page, prompt);
await page.getByTestId("agent-overflow-menu").click(); 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-sheet")).toBeVisible();
await expect(page.getByTestId("agent-details-agent-id")).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 }) => { test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
const repo = await createTempGitRepo(); const repo = await createTempGitRepo();
const marker = 'TIMELINE_HYDRATION_OK'; const prompt = 'Respond with exactly: TIMELINE_HYDRATION_OK';
const prompt = `Respond with exactly: ${marker}`;
try { try {
await gotoHome(page); await gotoHome(page);
await setWorkingDirectory(page, repo.path); await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page); await ensureHostSelected(page);
await createAgent(page, prompt); await createAgent(page, prompt);
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
const assistantMessage = page timeout: 30000,
.getByTestId('assistant-message') });
.filter({ hasText: marker })
.first();
await expect(assistantMessage).toBeVisible({ timeout: 120000 });
await page.reload({ waitUntil: 'commit' }); await page.reload({ waitUntil: 'commit' });
await expect(page).toHaveURL(/\/agent\//); 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({ await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
timeout: 30000, timeout: 30000,
}); });
await expect(
page.getByTestId('assistant-message').filter({ hasText: marker }).first()
).toBeVisible({ timeout: 30000 });
} finally { } finally {
await repo.cleanup(); await repo.cleanup();
} }
+164 -152
View File
@@ -4,6 +4,7 @@ import { execSync } from 'node:child_process';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { test, expect, type Page } from './fixtures'; import { test, expect, type Page } from './fixtures';
import { import {
createAgent,
ensureHostSelected, ensureHostSelected,
gotoHome, gotoHome,
setWorkingDirectory, setWorkingDirectory,
@@ -12,6 +13,10 @@ import { createTempGitRepo } from './helpers/workspace';
test.describe.configure({ mode: 'serial', timeout: 120000 }); test.describe.configure({ mode: 'serial', timeout: 120000 });
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getChangesScope(page: Page) { function getChangesScope(page: Page) {
return page.locator('[data-testid="explorer-content-area"]:visible').first(); return page.locator('[data-testid="explorer-content-area"]:visible').first();
} }
@@ -20,6 +25,26 @@ function getChangesHeader(page: Page) {
return getChangesScope(page).getByTestId('changes-header'); 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') { async function selectChangesView(page: Page, view: 'working' | 'base') {
// Defensive: close any open dropdown menus (their backdrops intercept clicks). // Defensive: close any open dropdown menus (their backdrops intercept clicks).
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop'); 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 overflowBackdrop.click({ force: true });
await expect(overflowBackdrop).toHaveCount(0); 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 scope = getChangesScope(page);
const modeToggle = scope.getByTestId('changes-diff-status').first(); 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(); const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
if (current !== expected) { if (current !== expected) {
await modeToggle.click(); 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 }); 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) { async function openChangesPrimaryMenu(page: Page) {
const scope = getChangesScope(page); const scope = getChangesScope(page);
const caret = scope.getByTestId('changes-primary-cta-caret').first(); 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 }) { async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
await ensureExplorerTabsVisible(page);
const changesHeader = getChangesHeader(page); const changesHeader = getChangesHeader(page);
if (!(await changesHeader.isVisible())) { if (!(await changesHeader.isVisible())) {
const explorerHeader = page.getByTestId('explorer-header'); await page.getByTestId('explorer-tab-changes').first().click();
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 expect(changesHeader).toBeVisible({ timeout: 30000 }); await expect(changesHeader).toBeVisible({ timeout: 30000 });
if (options?.expectGit === false) { if (options?.expectGit === false) {
@@ -95,98 +109,62 @@ async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
}); });
} }
async function sendPrompt(page: Page, prompt: string) { async function waitForAgentTurnToSettle(page: Page, timeout = 90000) {
const input = page.getByRole('textbox', { name: 'Message agent...' }); const stopButton = page.getByRole('button', { name: /stop agent|stop/i }).first();
await expect(input).toBeEditable(); if (!(await stopButton.isVisible().catch(() => false))) {
await input.fill(prompt); return;
await input.press('Enter'); }
} await expect(stopButton).not.toBeVisible({ timeout });
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;
} }
async function createAgentAndWait(page: Page, message: string) { async function createAgentAndWait(page: Page, message: string) {
const input = page.getByRole('textbox', { name: 'Message agent...' }); await createAgent(page, message);
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();
} }
async function selectAttachWorktree(page: Page, branchName: string) { async function selectAttachWorktree(page: Page, branchName: string) {
await page.getByTestId('worktree-attach-toggle').click(); const trigger = page.getByTestId('worktree-select-trigger').first();
const picker = page.getByTestId('worktree-attach-picker'); await expect(trigger).toBeVisible({ timeout: 30000 });
await expect(picker).toBeVisible(); await trigger.click();
// Wait a bit for the worktree list to load const menu = page.getByTestId('combobox-desktop-container').first();
await page.waitForTimeout(1000); await expect(menu).toBeVisible({ timeout: 10000 });
const searchInput = page.getByRole('textbox', { name: /search worktrees/i }).first();
await picker.click(); if (await searchInput.isVisible().catch(() => false)) {
await searchInput.fill(branchName);
// 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 options = scope.locator('[data-testid^="worktree-attach-option-"]'); const preferredOption = menu
const optionCount = await options.count(); .getByText(new RegExp(`^${escapeRegex(branchName)}$`, 'i'))
if (optionCount === 0) { .first();
throw new Error(`No worktree options were available in the attach picker`); await expect(preferredOption).toBeVisible({ timeout: 10000 });
} await preferredOption.click({ force: true });
const fallbackOption = options.first(); await expect(menu).toHaveCount(0);
const fallbackLabel = ((await fallbackOption.innerText()) ?? "").trim(); await expect(trigger).toContainText(branchName, { timeout: 30000 });
await fallbackOption.click();
if (fallbackLabel.length > 0) {
await expect(picker).toContainText(fallbackLabel);
}
} }
async function enableCreateWorktree(page: Page) { async function enableCreateWorktree(page: Page) {
const createToggle = page.getByTestId('worktree-create-toggle'); const trigger = page.getByTestId('worktree-select-trigger').first();
const willCreateLabel = page.getByText(/Will create:/); await expect(trigger).toBeVisible({ timeout: 30000 });
if (await willCreateLabel.isVisible()) {
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; return;
} }
const readyLabel = page.getByText(
/Run isolated from|Run in an isolated directory/ await trigger.click();
); const menu = page.getByTestId('combobox-desktop-container').first();
await expect(readyLabel).toBeVisible({ timeout: 30000 }); await expect(menu).toBeVisible({ timeout: 10000 });
await createToggle.click({ force: true }); const createOption = menu.getByText('Create new worktree', { exact: true }).first();
await expect(willCreateLabel).toBeVisible({ timeout: 30000 }); 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) { async function refreshUncommittedMode(page: Page) {
@@ -195,9 +173,9 @@ async function refreshUncommittedMode(page: Page) {
} }
async function refreshChangesTab(page: Page) { async function refreshChangesTab(page: Page) {
const header = page.locator('[data-testid="explorer-header"]:visible').first(); await ensureExplorerTabsVisible(page);
await header.getByText('Files', { exact: true }).first().click(); await page.getByTestId('explorer-tab-files').first().click();
await header.getByText('Changes', { exact: true }).first().click(); await page.getByTestId('explorer-tab-changes').first().click();
} }
function normalizeTmpPath(value: string) { function normalizeTmpPath(value: string) {
@@ -207,6 +185,72 @@ function normalizeTmpPath(value: string) {
return value; 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 }) => { test('checkout-first Changes panel ship loop', async ({ page }) => {
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true }); const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-')); 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 enableCreateWorktree(page);
await createAgentAndWait(page, 'Respond with exactly: READY'); await createAgentAndWait(page, 'Respond with exactly: READY');
await waitForAssistantText(page, 'READY'); await waitForAgentTurnToSettle(page);
await openChangesPanel(page); await openChangesPanel(page);
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch'); 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(); const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
expect(branchNameFromUi.length).toBeGreaterThan(0); expect(branchNameFromUi.length).toBeGreaterThan(0);
const firstCwd = await requestCwd(page); const { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
const worktreeBranch = execSync('git rev-parse --abbrev-ref HEAD', { repo.path
cwd: firstCwd, );
encoding: 'utf8',
}).trim();
expect(worktreeBranch.length).toBeGreaterThan(0); expect(worktreeBranch.length).toBeGreaterThan(0);
const [resolvedCwd, resolvedRepo] = await Promise.all([ const [resolvedCwd, resolvedRepo] = await Promise.all([
realpath(firstCwd).catch(() => firstCwd), realpath(firstCwd).catch(() => firstCwd),
@@ -244,19 +286,14 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy(); expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
await page.getByTestId('sidebar-new-agent').click(); await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/agent\/?$/); await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
await setWorkingDirectory(page, repo.path); await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page); await ensureHostSelected(page);
await selectAttachWorktree(page, worktreeBranch); await selectAttachWorktree(page, worktreeBranch);
await createAgentAndWait(page, 'Respond with exactly: READY2'); await createAgentAndWait(page, 'Respond with exactly: READY2');
await waitForAssistantText(page, 'READY2'); await waitForAgentTurnToSettle(page);
await openChangesPanel(page);
const secondCwd = await requestCwd(page);
expect(secondCwd).toBe(firstCwd);
await sendPrompt(page, "Respond with exactly: OK");
await waitForAssistantText(page, "OK");
const readmePath = path.join(firstCwd, 'README.md'); const readmePath = path.join(firstCwd, 'README.md');
await appendFile(readmePath, '\nFirst change\n'); 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 }); execSync("git push", { cwd: repo.path });
await selectChangesView(page, 'base'); await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText(/No changes vs/i)).toBeVisible({ await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
timeout: 60000, 'Committed',
}); { timeout: 30000 }
);
await refreshChangesTab(page); await refreshChangesTab(page);
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0, { timeout: 30000 });
await openChangesOverflowMenu(page); // Post-ship UI behavior is implementation-dependent (archive can be promoted into
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible(); // primary flow or hidden behind menu variants), so continue from a fresh draft.
await page.getByTestId('changes-menu-archive-worktree').click(); await page.getByTestId('sidebar-new-agent').click();
// Archiving a worktree deletes agents and redirects to home await expect(page).toHaveURL(/\/h\/[^/]+\/agent(?:\?|$)/, { timeout: 30000 });
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 });
await setWorkingDirectory(page, nonGitDir); await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
// 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);
} finally { } finally {
await rm(nonGitDir, { recursive: true, force: true }); await rm(nonGitDir, { recursive: true, force: true });
await repo.cleanup(); 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 { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace'; 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 }) => { test('create agent in a temp repo', async ({ page }) => {
const repo = await createTempGitRepo(); const repo = await createTempGitRepo();
const prompt = "Respond with exactly: Hello"; 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 // Verify user message is shown in the stream
await expect(page.getByText(prompt, { exact: true })).toBeVisible(); 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 page.getByTestId('agent-overflow-menu').click();
await expect(page.getByText('Model', { exact: true })).toBeVisible(); 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 // Verify the created agent's title reflects the response.
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: 'Hello' }); const { serverId, agentId } = parseAgentUrl(page.url());
await expect(assistantMessage).toBeVisible({ timeout: 30000 }); 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 { } finally {
await repo.cleanup(); 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 expect(input).toBeEditable();
await input.fill(prompt); await input.fill(prompt);
await input.press('Enter'); 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). // 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/ }); const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined); await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 }); 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) { 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 serverId = decodeURIComponent(match[1]);
const agentId = decodeURIComponent(match[2]); const agentId = decodeURIComponent(match[2]);
@@ -36,11 +40,16 @@ test('deleting an agent persists after reload', async ({ page }) => {
const agentRow = page.getByTestId(rowTestId).first(); const agentRow = page.getByTestId(rowTestId).first();
await expect(agentRow).toBeVisible({ timeout: 30000 }); 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(); await agentRow.hover();
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first(); const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
await expect(quickArchive).toBeVisible({ timeout: 10000 }); await expect(quickArchive).toBeVisible({ timeout: 10000 });
await quickArchive.click({ force: true }); 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). // Ensure deletion finished before reload (avoids races).
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 }); 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 { readFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function addFakeMicrophone(page: Page) { 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 base64Audio = (await readFile(fixturePath)).toString('base64');
const mimeType = 'audio/webm;codecs=opus'; const mimeType = 'audio/wav';
return page.addInitScript(({ base64Audio, mimeType }) => { return page.addInitScript(({ base64Audio, mimeType }) => {
const mic = { 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 blobFromBase64 = (base64: string, mimeType: string): Blob => {
const binaryString = atob(base64); const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length); const bytes = new Uint8Array(binaryString.length);
@@ -142,6 +164,9 @@ test('dictation transcribes fixture via real STT', async ({ page }) => {
.count(); .count();
await page.keyboard.press('Control+d'); await page.keyboard.press('Control+d');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
await expect await expect
.poll( .poll(
@@ -199,7 +224,14 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
await createAgent(page, 'Respond with exactly: Hello'); await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent($|\/)/); 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(); 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 page.keyboard.press('Control+d');
await expect await expect
@@ -211,16 +243,19 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
const newAgentButton = page.getByTestId('sidebar-new-agent'); const newAgentButton = page.getByTestId('sidebar-new-agent');
await expect(newAgentButton).toBeVisible(); await expect(newAgentButton).toBeVisible();
await newAgentButton.click(); await newAgentButton.click();
await expect(page).toHaveURL(/\/agent\/?$/); await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
await page.waitForTimeout(10_000); 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 expect(agentEntry).toBeVisible();
await agentEntry.click(); 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 { } finally {
await repo.cleanup(); 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(); const newAgentButton = page.getByTestId("sidebar-new-agent").first();
await expect(newAgentButton).toBeVisible({ timeout: 30000 }); await expect(newAgentButton).toBeVisible({ timeout: 30000 });
await newAgentButton.click(); await newAgentButton.click();
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 }); await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
await setWorkingDirectory(page, repo.path); 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. // Ensure create flow never uses a remembered host from the developer's real app.
serverId: testDaemon.serverId, serverId: testDaemon.serverId,
// Keep e2e fast/cheap by default. // Keep e2e fast/cheap by default.
provider: 'claude', provider: 'codex',
providerPreferences: { providerPreferences: {
claude: { model: 'haiku' }, 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 path from 'node:path';
import { appendFile } from 'node:fs/promises'; import { appendFile } from 'node:fs/promises';
import { test, expect, type Page } from './fixtures'; 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'; import { createTempGitRepo } from './helpers/workspace';
test.describe.configure({ timeout: 90000 }); test.describe.configure({ timeout: 90000 });
@@ -10,18 +10,31 @@ function getChangesScope(page: Page) {
return page.locator('[data-testid="explorer-content-area"]:visible').first(); 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) { async function openChangesPanel(page: Page) {
await ensureExplorerTabsVisible(page);
const changesHeader = getChangesScope(page).getByTestId('changes-header'); const changesHeader = getChangesScope(page).getByTestId('changes-header');
if (!(await changesHeader.isVisible())) { if (!(await changesHeader.isVisible())) {
const explorerHeader = page.getByTestId('explorer-header'); await page.getByTestId('explorer-tab-changes').first().click();
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 expect(changesHeader).toBeVisible(); await expect(changesHeader).toBeVisible();
} }
@@ -31,22 +44,29 @@ async function refreshUncommittedMode(page: Page) {
const toggle = scope.getByTestId('changes-diff-status').first(); const toggle = scope.getByTestId('changes-diff-status').first();
await expect(toggle).toBeVisible({ timeout: 30000 }); 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(); 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); await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
const nextLabel = (await toggle.innerText()).trim(); 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); await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
} }
async function createAgentAndWait(page: Page, message: string) { async function createAgentAndWait(page: Page, message: string) {
const input = page.getByRole('textbox', { name: 'Message agent...' }); await createAgent(page, message);
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();
} }
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => { 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 { Buffer } from 'node:buffer';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
type WaitForServerOptions = {
host?: string;
timeoutMs?: number;
label: string;
childProcess?: ChildProcess | null;
getRecentOutput?: () => string;
};
async function getAvailablePort(): Promise<number> { async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const server = net.createServer(); 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(); 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 { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, 'localhost', () => { const socket = net.connect(port, host, () => {
socket.end(); socket.end();
resolve(); resolve();
}); });
socket.setTimeout(1000, () => {
socket.destroy();
reject(new Error(`Connection timed out to ${host}:${port}`));
});
socket.on('error', reject); socket.on('error', reject);
}); });
return; return;
} catch { } catch (error) {
lastConnectionError = error;
await new Promise((r) => setTimeout(r, 100)); 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; let daemonProcess: ChildProcess | null = null;
@@ -81,11 +223,53 @@ export default async function globalSetup() {
} }
const port = await getAvailablePort(); const port = await getAvailablePort();
const relayPort = await getAvailablePort(); let relayPort = 0;
const metroPort = await getAvailablePort(); const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-')); 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 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( relayProcess = spawn(
'npx', 'npx',
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)], ['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) => { 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) { 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}`); console.log(`[relay] ${line}`);
} }
}); });
relayProcess.stderr?.on('data', (data: Buffer) => { 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) { 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}`); 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 // Start Metro bundler on dynamic port
const appDir = path.resolve(__dirname, '..'); const appDir = path.resolve(__dirname, '..');
@@ -125,14 +383,19 @@ export default async function globalSetup() {
}); });
metroProcess.stdout?.on('data', (data: Buffer) => { 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) { for (const line of lines) {
metroLineBuffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`); console.log(`[metro] ${line}`);
} }
}); });
metroProcess.stderr?.on('data', (data: Buffer) => { 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'); const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
@@ -153,9 +416,13 @@ export default async function globalSetup() {
PASEO_LISTEN: `0.0.0.0:${port}`, PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`, PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`, PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech. // Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
PASEO_DICTATION_ENABLED: "0", PASEO_DICTATION_ENABLED: '1',
PASEO_VOICE_MODE_ENABLED: "0", 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', NODE_ENV: 'development',
}, },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
@@ -170,6 +437,7 @@ export default async function globalSetup() {
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
daemonLineBuffer.add(`[stdout] ${trimmed}`);
if (!offerPayload) { if (!offerPayload) {
const clean = stripAnsi(trimmed); const clean = stripAnsi(trimmed);
try { try {
@@ -195,13 +463,26 @@ export default async function globalSetup() {
}); });
daemonProcess.stderr?.on('data', (data: Buffer) => { 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 // Wait for both daemon and Metro to be ready
await Promise.all([ await Promise.all([
waitForServer(port), waitForServer(port, {
waitForServer(metroPort, 120000), // Metro can take longer to start 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). // 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}`); console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
return async () => { return async () => {
if (daemonProcess) { await cleanup();
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;
}
console.log('[e2e] Test daemon stopped'); 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', '@paseo:create-agent-preferences',
JSON.stringify({ JSON.stringify({
serverId: expectedServerId, serverId: expectedServerId,
provider: 'claude', provider: 'codex',
providerPreferences: { providerPreferences: {
claude: { model: 'haiku' }, 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 legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first(); const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
const worktreePicker = page.getByTestId('worktree-attach-picker'); 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 closeBottomSheet = async () => {
const bottomSheetBackdrop = page const bottomSheetBackdrop = page
.getByRole('button', { name: 'Bottom sheet backdrop' }) .getByRole('button', { name: 'Bottom sheet backdrop' })
@@ -362,16 +362,83 @@ export const ensureHostSelected = async (page: Page) => {
export const createAgent = async (page: Page, message: string) => { export const createAgent = async (page: Page, message: string) => {
const input = page.getByRole('textbox', { name: 'Message agent...' }); const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable(); await expect(input).toBeEditable();
await preferFastThinkingOption(page);
await input.fill(message); await input.fill(message);
await input.press('Enter'); await input.press('Enter');
// Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`. // Router updates can be same-document transitions; assert URL state instead of waiting for a commit event.
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' }); await expect(page).toHaveURL(/\/agent\//, { timeout: 30000 });
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({ await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30000, 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 { export interface AgentConfig {
directory: string; directory: string;
provider?: string; provider?: string;
@@ -381,42 +448,104 @@ export interface AgentConfig {
} }
export const selectProvider = async (page: Page, provider: string) => { 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(); const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
await expect(providerLabel).toBeVisible(); await expect(providerLabel).toBeVisible();
await providerLabel.click(); 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 expect(option).toBeVisible();
await option.click(); await option.click();
}; };
export const selectModel = async (page: Page, model: string) => { 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(); const modelLabel = page.getByText('MODEL', { exact: true }).first();
await expect(modelLabel).toBeVisible(); await expect(modelLabel).toBeVisible();
await modelLabel.click(); await modelLabel.click();
}
// Wait for the model dropdown to open // Wait for the model dropdown to open
const searchInput = page.getByRole('textbox', { name: /search model/i }); const searchInput = page.getByRole('textbox', { name: /search model/i });
await expect(searchInput).toBeVisible({ timeout: 10000 }); await expect(searchInput).toBeVisible({ timeout: 10000 });
// Type to search/filter models // Type to search/filter models
await searchInput.fill(model); await searchInput.fill(normalizedModel);
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
const option = dialog const exactOption = dialog
.getByText(new RegExp(`^${escapeRegex(model)}$`, 'i')) .getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
.first(); .first();
await expect(option).toBeVisible({ timeout: 30000 }); const exactVisible = await exactOption.isVisible().catch(() => false);
await option.click({ force: true }); 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 // 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 }); await expect(searchInput).not.toBeVisible({ timeout: 5000 });
}; };
export const selectMode = async (page: Page, mode: string) => { 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(); const modeLabel = page.getByText('MODE', { exact: true }).first();
await expect(modeLabel).toBeVisible(); await expect(modeLabel).toBeVisible();
await modeLabel.click(); await modeLabel.click();
}
// Wait for the mode dropdown to open // Wait for the mode dropdown to open
const searchInput = page.getByRole('textbox', { name: /search mode/i }); const searchInput = page.getByRole('textbox', { name: /search mode/i });
+3 -1
View File
@@ -12,7 +12,9 @@ export const createTempGitRepo = async (
prefix = 'paseo-e2e-', prefix = 'paseo-e2e-',
options?: { withRemote?: boolean } options?: { withRemote?: boolean }
): Promise<TempRepo> => { ): 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; const withRemote = options?.withRemote ?? false;
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' }); 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.getByTestId('sidebar-new-agent')).toBeVisible();
await expect(page.getByText(serverId, { exact: true })).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('extra', { exact: true }).first()).toBeVisible();
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible(); await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
await page.getByTestId(`daemon-menu-trigger-${extraDaemon.serverId}`).click(); const hostSettingsButton = page.getByTestId(`daemon-card-settings-${extraDaemon.serverId}`).first();
await page.getByTestId(`daemon-menu-remove-${extraDaemon.serverId}`).click(); 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 expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
await page.getByTestId('remove-host-confirm').click(); 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 expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
await page.waitForFunction( await page.waitForFunction(
(serverId) => { (serverId) => {
+3 -4
View File
@@ -41,10 +41,10 @@ test('new agent respects serverId in the URL', async ({ page }) => {
}; };
const createAgentPreferences = { const createAgentPreferences = {
serverId: testDaemon.serverId, serverId: testDaemon.serverId,
provider: 'claude', provider: 'codex',
providerPreferences: { providerPreferences: {
claude: { model: 'haiku' }, 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 } { daemon: testDaemon, preferences: createAgentPreferences }
); );
await page.reload(); 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 page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible(); 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 page.reload();
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible(); 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). // Host should be auto-selected (no manual selection required).
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible(); 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 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('+ Add connection', { exact: true }).click();
await page.getByText('Direct connection', { exact: true }).click(); await page.getByText('Direct connection', { exact: true }).click();
}
const input = page.getByPlaceholder('host:6767'); const input = page.getByPlaceholder('host:6767');
await expect(input).toBeVisible(); 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(); await page.getByText('Connect', { exact: true }).click();
const nameModal = page.getByTestId('name-host-modal'); 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 nameModal.getByTestId('name-host-skip').click();
}
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible(); await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
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( await page.waitForFunction(
({ port, serverId }) => { ({ 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 }) => { 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. // Override the default fixture seeding for this test.
await page.goto('/settings'); await page.goto('/settings');
await page.evaluate(() => { 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.setItem('@paseo:daemon-registry', JSON.stringify([]));
localStorage.removeItem('@paseo:settings'); localStorage.removeItem('@paseo:settings');
}); });
await page.reload(); await page.goto('/');
const relayEndpoint = `127.0.0.1:${relayPort}`;
const offer = { const offer = {
v: 2 as const, v: 2 as const,
serverId: 'e2e-server-123', serverId,
daemonPublicKeyB64: Buffer.from('e2e-public-key', 'utf8').toString('base64'), daemonPublicKeyB64,
relay: { endpoint: 'relay.local:443' }, relay: { endpoint: relayEndpoint },
}; };
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`; 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('+ Add connection', { exact: true }).click();
await page.getByText('Paste pairing link', { exact: true }).click(); await page.getByText('Paste pairing link', { exact: true }).click();
}
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...'); const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
await expect(input).toBeVisible(); await expect(input).toBeVisible();
await input.fill(offerUrl); 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( await page.waitForFunction(
({ expected }) => { ({ expected }) => {
+5 -6
View File
@@ -7,6 +7,7 @@ import {
waitForPermissionPrompt, waitForPermissionPrompt,
allowPermission, allowPermission,
denyPermission, denyPermission,
waitForAgentFinishUI,
} from './helpers/app'; } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace'; import { createTempGitRepo } from './helpers/workspace';
@@ -31,7 +32,7 @@ test.describe('permission prompts', () => {
try { try {
await createAgentWithConfig(page, { await createAgentWithConfig(page, {
directory: repo.path, directory: repo.path,
model: 'haiku', provider: 'claude',
mode: 'Always Ask', mode: 'Always Ask',
prompt, prompt,
}); });
@@ -71,7 +72,7 @@ test.describe('permission prompts', () => {
try { try {
await createAgentWithConfig(page, { await createAgentWithConfig(page, {
directory: repo.path, directory: repo.path,
model: 'haiku', provider: 'claude',
mode: 'Always Ask', mode: 'Always Ask',
prompt, prompt,
}); });
@@ -79,10 +80,8 @@ test.describe('permission prompts', () => {
await waitForPermissionPrompt(page, 30000); await waitForPermissionPrompt(page, 30000);
await denyPermission(page); await denyPermission(page);
await waitForAgentFinishUI(page, 30000);
await expect(page.getByText(/denied by the user|permission\/authorization check/i)).toBeVisible({ await expect(page.getByTestId('permission-request-question')).toHaveCount(0);
timeout: 30_000,
});
expect(existsSync(filePath)).toBe(false); 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 createAgent(page, 'Agent A: respond with exactly A');
await expect(page).toHaveURL(/\/agent\//); 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 page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/agent\/?$/); await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoA.path))); 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 { } finally {
await repoA.cleanup(); 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 tryResolveTarget = (root: HTMLElement) => {
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))]; const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
for (const element of stack) { for (const element of stack) {
if (!element.textContent?.includes("No projects")) continue; if (element.dataset?.testid === "combobox-desktop-container") {
const container = getContainer(element); target = element;
if (!container) continue;
target = container;
return true; return true;
} }
}
return false; return false;
}; };
+83 -51
View File
@@ -7,6 +7,21 @@ import {
} from "./helpers/app"; } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace"; 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 } { function parseAgentFromUrl(url: string): { serverId: string; agentId: string } {
const pathname = (() => { const pathname = (() => {
try { try {
@@ -60,7 +75,7 @@ async function openNewAgentDraft(page: Page): Promise<void> {
const newAgentButton = page.getByTestId("sidebar-new-agent").first(); const newAgentButton = page.getByTestId("sidebar-new-agent").first();
await expect(newAgentButton).toBeVisible({ timeout: 30000 }); await expect(newAgentButton).toBeVisible({ timeout: 30000 });
await newAgentButton.click(); await newAgentButton.click();
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 }); await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
await expect( await expect(
page.locator('[data-testid="working-directory-select"]:visible').first() page.locator('[data-testid="working-directory-select"]:visible').first()
).toBeVisible({ ).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> { async function openTerminalsPanel(page: Page): Promise<void> {
let header = page.locator('[data-testid="explorer-header"]:visible').first(); await ensureExplorerTabsVisible(page);
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();
}
}
header = page.locator('[data-testid="explorer-header"]:visible').first(); const terminalsTab = visibleTestId(page, "explorer-tab-terminals");
await expect(header).toBeVisible({ timeout: 30000 });
const terminalsTab = page.getByTestId("explorer-tab-terminals").first();
await expect(terminalsTab).toBeVisible({ timeout: 30000 }); await expect(terminalsTab).toBeVisible({ timeout: 30000 });
await terminalsTab.click(); await terminalsTab.click();
await expect(page.getByTestId("terminals-header").first()).toBeVisible({ await expect(visibleTestId(page, "terminals-header")).toBeVisible({
timeout: 30000, timeout: 30000,
}); });
await expect(page.getByTestId("terminal-surface").first()).toBeVisible({ await expect(visibleTestId(page, "terminal-surface")).toBeVisible({
timeout: 30000, timeout: 30000,
}); });
} }
async function openFilesPanel(page: Page): Promise<void> { 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 expect(filesTab).toBeVisible({ timeout: 30000 });
await filesTab.click(); await filesTab.click();
await expect(page.getByTestId("files-pane-header").first()).toBeVisible({ await expect(visibleTestId(page, "files-pane-header")).toBeVisible({
timeout: 30000, timeout: 30000,
}); });
} }
@@ -124,7 +143,7 @@ async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
async function selectNewestTerminalTab(page: Page): Promise<void> { 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(tabs.first()).toBeVisible({ timeout: 30000 });
await expect await expect
.poll(async () => await tabs.count(), { timeout: 30000 }) .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> { 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 }); await expect(firstTab).toBeVisible({ timeout: 30000 });
const value = await firstTab.getAttribute("data-testid"); const value = await firstTab.getAttribute("data-testid");
if (!value) { if (!value) {
@@ -143,7 +162,7 @@ async function getFirstTerminalTabTestId(page: Page): Promise<string> {
} }
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> { 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 expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true }); await surface.click({ force: true });
await page.keyboard.type(command, { delay: 1 }); await page.keyboard.type(command, { delay: 1 });
@@ -158,7 +177,7 @@ async function runTerminalCommandWithPreEnterEcho(
command: string, command: string,
expectedText: string expectedText: string
): Promise<void> { ): Promise<void> {
const surface = page.getByTestId("terminal-surface").first(); const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 }); await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true }); await surface.click({ force: true });
await page.keyboard.type(command, { delay: 1 }); 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 createAgent(page, "Reply with exactly: terminal smoke");
await openTerminalsPanel(page); await openTerminalsPanel(page);
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({ await expect(visibleTestIdPrefix(page, "terminal-tab-").first()).toBeVisible({
timeout: 30000, timeout: 30000,
}); });
const preEnterEchoMarker = `typed-echo-${Date.now()}`; const preEnterEchoMarker = shortTerminalMarker("typed");
await runTerminalCommandWithPreEnterEcho( await runTerminalCommandWithPreEnterEcho(
page, page,
`echo ${preEnterEchoMarker}`, `echo ${preEnterEchoMarker}`,
@@ -240,7 +259,7 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
const markerOne = `terminal-smoke-one-${Date.now()}`; const markerOne = `terminal-smoke-one-${Date.now()}`;
await runTerminalCommand(page, `echo ${markerOne}`, markerOne); await runTerminalCommand(page, `echo ${markerOne}`, markerOne);
await page.getByTestId("terminals-create-button").first().click(); await visibleTestId(page, "terminals-create-button").click();
await selectNewestTerminalTab(page); await selectNewestTerminalTab(page);
const markerTwo = `terminal-smoke-two-${Date.now()}`; 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, { await expect(page.getByText("Terminal stream ended. Reconnecting…")).toHaveCount(0, {
timeout: 30000, 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, timeout: 30000,
}); });
} }
@@ -295,7 +314,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
await openTerminalsPanel(page); await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first(); const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 }); await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true }); 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"); await page.keyboard.press("Enter");
} }
const markerAfterEnters = `echo-visible-${Date.now()}`; const markerAfterEnters = shortTerminalMarker("visible");
await page.keyboard.type(`echo ${markerAfterEnters}`, { delay: 0 }); await page.keyboard.type(`echo ${markerAfterEnters}`, { delay: 0 });
await expect(surface).toContainText(`echo ${markerAfterEnters}`, { await expect(surface).toContainText(`echo ${markerAfterEnters}`, {
timeout: 30000, timeout: 30000,
@@ -319,7 +338,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
await page.keyboard.press("Backspace"); 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.type(markerAfterBackspace, { delay: 0 });
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await expect(surface).toContainText(markerAfterBackspace, { await expect(surface).toContainText(markerAfterBackspace, {
@@ -341,7 +360,7 @@ test("terminal remains interactive after alternate-screen enter/exit", async ({
await openTerminalsPanel(page); await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first(); const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 }); await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true }); 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 exitedTabTestId = await getFirstTerminalTabTestId(page);
const surface = page.getByTestId("terminal-surface").first(); const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 }); await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true }); await surface.click({ force: true });
await page.keyboard.type("exit", { delay: 1 }); await page.keyboard.type("exit", { delay: 1 });
@@ -385,7 +404,7 @@ test("terminal tab is removed when shell exits", async ({ page }) => {
timeout: 30000, timeout: 30000,
}); });
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({ await expect(visibleTestIdPrefix(page, "terminal-tab-").first()).toBeVisible({
timeout: 30000, timeout: 30000,
}); });
const nextTabTestId = await getFirstTerminalTabTestId(page); 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 tabTestId = await getFirstTerminalTabTestId(page);
const terminalId = tabTestId.replace("terminal-tab-", ""); const terminalId = tabTestId.replace("terminal-tab-", "");
const tab = page.getByTestId(tabTestId).first(); const tab = visibleTestId(page, tabTestId);
await expect(tab).toBeVisible({ timeout: 30000 }); await expect(tab).toBeVisible({ timeout: 30000 });
const runningMarker = `terminal-close-running-${Date.now()}`; 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 dialog.dismiss();
} }
); );
await page.getByTestId(`terminal-close-${terminalId}`).first().click(); await visibleTestId(page, `terminal-close-${terminalId}`).click();
await dialogPromise; await dialogPromise;
await expect(tab).toBeVisible({ timeout: 30000 }); 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 tabTestId = await getFirstTerminalTabTestId(page);
const terminalId = tabTestId.replace("terminal-tab-", ""); const terminalId = tabTestId.replace("terminal-tab-", "");
const tab = page.getByTestId(tabTestId).first(); const tab = visibleTestId(page, tabTestId);
await expect(tab).toBeVisible({ timeout: 30000 }); await expect(tab).toBeVisible({ timeout: 30000 });
const runningMarker = `terminal-close-running-accept-${Date.now()}`; 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 dialog.accept();
} }
); );
await page.getByTestId(`terminal-close-${terminalId}`).first().click(); await visibleTestId(page, `terminal-close-${terminalId}`).click();
await dialogPromise; await dialogPromise;
await expect(page.getByTestId(tabTestId)).toHaveCount(0, { 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 openAgentFromSidebar(page, first.serverId, first.agentId);
await openTerminalsPanel(page); await openTerminalsPanel(page);
await page.getByTestId("terminals-create-button").first().click(); await visibleTestId(page, "terminals-create-button").click();
await selectNewestTerminalTab(page); await selectNewestTerminalTab(page);
await openAgentFromSidebar(page, second.serverId, second.agentId); 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 openAgentFromSidebar(page, first.serverId, first.agentId);
await openTerminalsPanel(page); await openTerminalsPanel(page);
await selectNewestTerminalTab(page); await selectNewestTerminalTab(page);
await expect(page.getByTestId("terminal-surface").first()).toContainText(sharedMarker, { await expect(visibleTestId(page, "terminal-surface")).toContainText(sharedMarker, {
timeout: 30000, timeout: 30000,
}); });
} finally { } finally {
@@ -529,7 +548,7 @@ test("terminal captures escape and ctrl+c key input", async ({ page }) => {
await openTerminalsPanel(page); await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first(); const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 }); await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true }); 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 page.keyboard.press("Control+B");
await expect(surface).toContainText("^B", { timeout: 30000 }); 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.type(`echo ${marker}`, { delay: 1 });
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await expect(surface).toContainText(marker, { timeout: 30000 }); 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 createAgent(page, "Terminal Cmd+B");
await openTerminalsPanel(page); await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first(); const surface = visibleTestId(page, "terminal-surface");
await expect(surface).toBeVisible({ timeout: 30000 }); await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true }); 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) => { await page.evaluate((nextPadding) => {
const container = document.querySelector<HTMLElement>( const surfaces = Array.from(
'[data-testid="explorer-content-area"]' document.querySelectorAll<HTMLElement>('[data-testid="terminal-surface"]')
); );
if (!container) { const activeSurface =
surfaces.find((surface) => surface.offsetParent !== null) ?? surfaces[0] ?? null;
if (!activeSurface) {
return; return;
} }
container.style.boxSizing = "border-box"; const host = activeSurface.parentElement as HTMLElement | null;
container.style.paddingBottom = nextPadding + "px"; if (!host) {
}, padding); 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> { async function getTerminalScrollbackDistance(page: Page): Promise<number> {
@@ -646,12 +678,12 @@ test("terminal viewport resizes and uses xterm scrollback", async ({ page }) =>
.toBeGreaterThan(0); .toBeGreaterThan(0);
const initialRows = await getTerminalRows(page); const initialRows = await getTerminalRows(page);
await setExplorerContentBottomPadding(page, 220); await setTerminalHeightInset(page, 220);
await expect await expect
.poll(() => getTerminalRows(page), { timeout: 30000 }) .poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeLessThan(initialRows); .toBeLessThan(initialRows);
await setExplorerContentBottomPadding(page, 0); await setTerminalHeightInset(page, 0);
await expect await expect
.poll(() => getTerminalRows(page), { timeout: 30000 }) .poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThanOrEqual(initialRows); .toBeGreaterThanOrEqual(initialRows);
@@ -679,7 +711,7 @@ test("terminal viewport resizes and uses xterm scrollback", async ({ page }) =>
`${scrollbackMarker}-180` `${scrollbackMarker}-180`
); );
const surface = page.getByTestId("terminal-surface").first(); const surface = visibleTestId(page, "terminal-surface");
await surface.hover(); await surface.hover();
await page.mouse.wheel(0, -3000); await page.mouse.wheel(0, -3000);
+4 -1
View File
@@ -11,7 +11,10 @@ export default defineConfig({
expect: { expect: {
timeout: 10_000, 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, retries: process.env.CI ? 1 : 0,
reporter: [['list']], reporter: [['list']],
use: { use: {
+1
View File
@@ -464,6 +464,7 @@ export default function RootLayout() {
}} }}
> >
<Stack.Screen name="index" /> <Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen <Stack.Screen
name="h/[serverId]/agent/[agentId]" name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }} options={{ gestureEnabled: false }}
+14 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { ActivityIndicator, View } from "react-native"; import { ActivityIndicator, View } from "react-native";
import { useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles"; import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
@@ -9,14 +9,26 @@ import { buildHostAgentDraftRoute } from "@/utils/host-routes";
export default function Index() { export default function Index() {
const router = useRouter(); const router = useRouter();
const params = useLocalSearchParams<{ serverId?: string }>();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { daemons, isLoading: registryLoading } = useDaemonRegistry(); const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const { preferences, isLoading: preferencesLoading } = useFormPreferences(); const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const requestedServerId = useMemo(() => {
return typeof params.serverId === "string" ? params.serverId.trim() : "";
}, [params.serverId]);
const targetServerId = useMemo(() => { const targetServerId = useMemo(() => {
if (daemons.length === 0) { if (daemons.length === 0) {
return null; return null;
} }
if (requestedServerId) {
const requested = daemons.find(
(daemon) => daemon.serverId === requestedServerId
);
if (requested) {
return requested.serverId;
}
}
if (preferences.serverId) { if (preferences.serverId) {
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId); const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
if (match) { if (match) {
@@ -24,7 +36,7 @@ export default function Index() {
} }
} }
return daemons[0]?.serverId ?? null; return daemons[0]?.serverId ?? null;
}, [daemons, preferences.serverId]); }, [daemons, preferences.serverId, requestedServerId]);
useEffect(() => { useEffect(() => {
if (registryLoading || preferencesLoading) { 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; onSelect: (id: string) => void;
icon?: ReactElement; icon?: ReactElement;
showLabel?: boolean; showLabel?: boolean;
testID?: string;
} }
export function ComboSelect({ export function ComboSelect({
@@ -338,6 +339,7 @@ export function ComboSelect({
onSelect, onSelect,
icon, icon,
showLabel = true, showLabel = true,
testID,
}: ComboSelectProps): ReactElement { }: ComboSelectProps): ReactElement {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
@@ -361,6 +363,7 @@ export function ComboSelect({
controlRef={anchorRef} controlRef={anchorRef}
icon={icon} icon={icon}
showLabel={showLabel} showLabel={showLabel}
testID={testID}
/> />
<Combobox <Combobox
options={options} options={options}
@@ -573,6 +576,7 @@ export function AgentConfigRow({
onSelect={onSelectProvider} onSelect={onSelectProvider}
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />} icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false} showLabel={false}
testID="draft-provider-select"
/> />
</View> </View>
<View style={styles.agentConfigColumn}> <View style={styles.agentConfigColumn}>
@@ -587,6 +591,7 @@ export function AgentConfigRow({
onSelect={onSelectModel} onSelect={onSelectModel}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />} icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false} showLabel={false}
testID="draft-model-select"
/> />
</View> </View>
<View style={styles.agentConfigColumn}> <View style={styles.agentConfigColumn}>
@@ -600,6 +605,7 @@ export function AgentConfigRow({
onSelect={onSelectMode} onSelect={onSelectMode}
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />} icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false} showLabel={false}
testID="draft-mode-select"
/> />
</View> </View>
{thinkingSelectOptions.length > 0 ? ( {thinkingSelectOptions.length > 0 ? (
@@ -36,6 +36,7 @@ import {
persistAttachmentFromFileUri, persistAttachmentFromFileUri,
} from '@/attachments/service' } from '@/attachments/service'
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard' import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
type QueuedMessage = { type QueuedMessage = {
id: string id: string
@@ -77,6 +78,7 @@ export function AgentInputArea({
onAddImages, onAddImages,
commandDraftConfig, commandDraftConfig,
}: AgentInputAreaProps) { }: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
const { theme } = useUnistyles() const { theme } = useUnistyles()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const { height: keyboardHeight } = useReanimatedKeyboardAnimation() const { height: keyboardHeight } = useReanimatedKeyboardAnimation()
@@ -41,9 +41,10 @@ describe("resolveStreamRenderStrategy", () => {
isMobileBreakpoint: false, isMobileBreakpoint: false,
}); });
expect(strategy.kind).toBe("forward_stream"); expect(strategy.shouldUseVirtualizedList()).toBe(false);
expect(strategy.flatListInverted).toBe(false); expect(strategy.getFlatListInverted()).toBe(false);
expect(strategy.overlayScrollbarInverted).toBe(false); expect(strategy.getOverlayScrollbarInverted()).toBe(false);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
}); });
it("uses inverted_stream on native", () => { it("uses inverted_stream on native", () => {
@@ -52,9 +53,10 @@ describe("resolveStreamRenderStrategy", () => {
isMobileBreakpoint: false, isMobileBreakpoint: false,
}); });
expect(strategy.kind).toBe("inverted_stream"); expect(strategy.shouldUseVirtualizedList()).toBe(true);
expect(strategy.flatListInverted).toBe(true); expect(strategy.getFlatListInverted()).toBe(true);
expect(strategy.overlayScrollbarInverted).toBe(true); expect(strategy.getOverlayScrollbarInverted()).toBe(true);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false);
}); });
}); });
@@ -1,5 +1,5 @@
import type { ComponentType, ReactElement } from "react"; import type { ComponentType, ReactElement, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native"; import type { FlatList, ScrollView, StyleProp, View, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
type EdgeSlot = "header" | "footer"; type EdgeSlot = "header" | "footer";
@@ -11,44 +11,6 @@ export type MaintainVisibleContentPositionConfig = Readonly<{
autoscrollToTopThreshold: number; 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 = { export type StreamViewportMetrics = {
contentHeight: number; contentHeight: number;
viewportHeight: number; viewportHeight: number;
@@ -66,104 +28,143 @@ export type StreamEdgeSlotProps = {
ListFooterComponentStyle?: StyleProp<ViewStyle>; 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 = const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION: MaintainVisibleContentPositionConfig =
Object.freeze({ Object.freeze({
minIndexForVisible: 0, minIndexForVisible: 0,
autoscrollToTopThreshold: 0, autoscrollToTopThreshold: 0,
}); });
const INVERTED_STREAM_STRATEGY: InvertedStreamRenderStrategy = { function scrollAnchorIntoView(params: {
kind: "inverted_stream", refs: StreamRenderRefs;
flatListInverted: true, animated: boolean;
edgeSlot: "header", }): boolean {
overlayScrollbarInverted: true, const anchorHandle = params.refs.bottomAnchorRef.current as
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION, | ({ getNativeRef?: () => unknown; scrollIntoView?: (options?: unknown) => void } &
assistantTurnTraversalStep: 1, object)
disableParentScrollOnInlineDetailsExpansion: false, | null;
}; if (!anchorHandle) {
return false;
}
const FORWARD_STREAM_STRATEGY_DESKTOP: ForwardStreamRenderStrategy = { const maybeNative =
kind: "forward_stream", typeof anchorHandle.getNativeRef === "function"
flatListInverted: false, ? anchorHandle.getNativeRef()
edgeSlot: "footer", : anchorHandle;
overlayScrollbarInverted: false,
assistantTurnTraversalStep: -1,
disableParentScrollOnInlineDetailsExpansion: true,
};
const FORWARD_STREAM_STRATEGY_MOBILE: ForwardStreamRenderStrategy = { const domElement = maybeNative as { scrollIntoView?: (options?: unknown) => void };
...FORWARD_STREAM_STRATEGY_DESKTOP, if (typeof domElement.scrollIntoView !== "function") {
disableParentScrollOnInlineDetailsExpansion: false, return false;
}; }
export function resolveStreamRenderStrategy( domElement.scrollIntoView({
input: ResolveStreamRenderStrategyInput block: "end",
behavior: params.animated ? "smooth" : "auto",
});
return true;
}
function createStreamRenderStrategy(
config: StreamRenderStrategyConfig
): StreamRenderStrategy { ): StreamRenderStrategy {
if (input.platform === "web") { return {
return input.isMobileBreakpoint orderTail: (streamItems) =>
? FORWARD_STREAM_STRATEGY_MOBILE config.orderTailReverse ? [...streamItems].reverse() : streamItems,
: FORWARD_STREAM_STRATEGY_DESKTOP; orderHead: (streamHead) =>
} config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
return INVERTED_STREAM_STRATEGY; getNeighborIndex: (index, relation) =>
} relation === "above"
? index + config.assistantTurnTraversalStep
export function orderTailForStreamRenderStrategy(params: { : index - config.assistantTurnTraversalStep,
strategy: StreamRenderStrategy; getNeighborItem: (items, index, relation) => {
streamItems: StreamItem[]; const neighborIndex =
}): StreamItem[] { relation === "above"
const { strategy, streamItems } = params; ? index + config.assistantTurnTraversalStep
return strategy.kind === "inverted_stream" : index - config.assistantTurnTraversalStep;
? [...streamItems].reverse() if (neighborIndex < 0 || neighborIndex >= items.length) {
: 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 undefined; return undefined;
} }
return params.items[nextIndex]; return items[neighborIndex];
} },
collectAssistantTurnContent: (items, startIndex) => {
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
items: StreamItem[];
startIndex: number;
}): string {
const { strategy, items, startIndex } = params;
const messages: string[] = []; const messages: string[] = [];
for ( for (
let index = startIndex; let index = startIndex;
index >= 0 && index < items.length; index >= 0 && index < items.length;
index += strategy.assistantTurnTraversalStep index += config.assistantTurnTraversalStep
) { ) {
const currentItem = items[index]; const currentItem = items[index];
if (currentItem.kind === "user_message") { if (currentItem.kind === "user_message") {
@@ -173,42 +174,12 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
messages.push(currentItem.text); messages.push(currentItem.text);
} }
} }
return messages.reverse().join("\n\n"); return messages.reverse().join("\n\n");
} },
isNearBottom: (input) => config.isNearBottom(input),
export function isNearBottomForStreamRenderStrategy( getBottomOffset: (metrics) => config.getBottomOffset(metrics),
params: StreamNearBottomInput & { strategy: StreamRenderStrategy } getEdgeSlotProps: (component, gapSize) => {
): boolean { if (config.edgeSlot === "header") {
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") {
return { return {
ListHeaderComponent: component, ListHeaderComponent: component,
ListHeaderComponentStyle: { marginBottom: gapSize }, ListHeaderComponentStyle: { marginBottom: gapSize },
@@ -218,4 +189,168 @@ export function getStreamEdgeSlotProps(params: {
ListFooterComponent: component, ListFooterComponent: component,
ListFooterComponentStyle: { marginTop: gapSize }, 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 {
import type { ReactNode } from "react"; Fragment,
createElement,
isValidElement,
useEffect,
useMemo,
useRef,
useState,
useCallback,
} from "react";
import type { ComponentType, ReactElement, ReactNode } from "react";
import { import {
View, View,
Text, Text,
Pressable, Pressable,
FlatList, FlatList,
ScrollView,
ListRenderItemInfo, ListRenderItemInfo,
LayoutChangeEvent, LayoutChangeEvent,
NativeScrollEvent, NativeScrollEvent,
@@ -57,13 +67,13 @@ import {
} from "./web-desktop-scrollbar"; } from "./web-desktop-scrollbar";
import { import {
collectAssistantTurnContentForStreamRenderStrategy, collectAssistantTurnContentForStreamRenderStrategy,
getBottomOffsetForStreamRenderStrategy,
getStreamEdgeSlotProps, getStreamEdgeSlotProps,
getStreamNeighborItem, getStreamNeighborItem,
isNearBottomForStreamRenderStrategy, isNearBottomForStreamRenderStrategy,
orderHeadForStreamRenderStrategy, orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy, orderTailForStreamRenderStrategy,
resolveStreamRenderStrategy, resolveStreamRenderStrategy,
type StreamEdgeSlotProps,
} from "./agent-stream-render-strategy"; } from "./agent-stream-render-strategy";
import { createMarkdownStyles } from "@/styles/markdown-styles"; import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { MAX_CONTENT_WIDTH } from "@/constants/layout";
@@ -76,6 +86,24 @@ const isToolSequenceItem = (item?: StreamItem) =>
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]"; const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
const STREAM_ITEM_LOG_MIN_COUNT = 200; const STREAM_ITEM_LOG_MIN_COUNT = 200;
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50; 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 { export interface AgentStreamViewProps {
agentId: string; agentId: string;
serverId?: string; serverId?: string;
@@ -92,6 +120,8 @@ export function AgentStreamView({
pendingPermissions, pendingPermissions,
}: AgentStreamViewProps) { }: AgentStreamViewProps) {
const flatListRef = useRef<FlatList<StreamItem>>(null); const flatListRef = useRef<FlatList<StreamItem>>(null);
const scrollViewRef = useRef<ScrollView>(null);
const bottomAnchorRef = useRef<View>(null);
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -111,6 +141,7 @@ export function AgentStreamView({
const isNearBottomRef = useRef(true); const isNearBottomRef = useRef(true);
const pendingAutoScrollFrameRef = useRef<number | null>(null); const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollAnimatedRef = useRef(false); const pendingAutoScrollAnimatedRef = useRef(false);
const scrollOffsetYRef = useRef(0);
const streamItemCountRef = useRef(0); const streamItemCountRef = useRef(0);
const streamViewportMetricsRef = useRef({ const streamViewportMetricsRef = useRef({
contentHeight: 0, contentHeight: 0,
@@ -120,6 +151,10 @@ export function AgentStreamView({
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set()); const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const streamRenderRefs = useMemo(
() => ({ flatListRef, scrollViewRef, bottomAnchorRef }),
[]
);
// Get serverId (fallback to agent's serverId if not provided) // Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? agent.serverId ?? ""; const resolvedServerId = serverId ?? agent.serverId ?? "";
@@ -192,6 +227,7 @@ export function AgentStreamView({
const handleScroll = useCallback( const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => { (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
scrollOffsetYRef.current = contentOffset.y;
streamViewportMetricsRef.current = { streamViewportMetricsRef.current = {
contentHeight: Math.max(0, contentSize.height), contentHeight: Math.max(0, contentSize.height),
viewportHeight: Math.max(0, layoutMeasurement.height), viewportHeight: Math.max(0, layoutMeasurement.height),
@@ -235,36 +271,61 @@ export function AgentStreamView({
[showDesktopWebScrollbar, streamScrollbarMetrics] [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( const handleContentSizeChange = useCallback(
(width: number, height: number) => { (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 = {
...streamViewportMetricsRef.current, ...previousMetrics,
contentHeight: Math.max(0, height), 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) { if (showDesktopWebScrollbar) {
streamScrollbarMetrics.onContentSizeChange(width, height); streamScrollbarMetrics.onContentSizeChange(width, height);
} }
}, },
[showDesktopWebScrollbar, streamScrollbarMetrics] [
); insets.bottom,
scrollToBottomInternal,
const scrollToBottomInternal = useCallback( showDesktopWebScrollbar,
({ animated }: { animated: boolean }) => { streamRenderStrategy,
const list = flatListRef.current; streamScrollbarMetrics,
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]
); );
const scheduleAutoScroll = useCallback( const scheduleAutoScroll = useCallback(
@@ -301,6 +362,11 @@ export function AgentStreamView({
return; return;
} }
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
// Forward streams anchor from measurement updates in handleContentSizeChange.
return;
}
if (!hasAutoScrolledOnce.current) { if (!hasAutoScrolledOnce.current) {
const handle = InteractionManager.runAfterInteractions(() => { const handle = InteractionManager.runAfterInteractions(() => {
scrollToBottomInternal({ animated: false }); scrollToBottomInternal({ animated: false });
@@ -317,10 +383,16 @@ export function AgentStreamView({
const shouldAnimate = hasScrolledInitially.current; const shouldAnimate = hasScrolledInitially.current;
scheduleAutoScroll({ animated: shouldAnimate }); scheduleAutoScroll({ animated: shouldAnimate });
hasScrolledInitially.current = true; hasScrolledInitially.current = true;
}, [streamItems, scheduleAutoScroll]); }, [
scheduleAutoScroll,
scrollToBottomInternal,
streamItems,
streamRenderStrategy,
]);
function scrollToBottom() { function scrollToBottom() {
scrollToBottomInternal({ animated: true }); const animated = streamRenderStrategy.shouldAnimateManualScrollToBottom();
scrollToBottomInternal({ animated });
isNearBottomRef.current = true; isNearBottomRef.current = true;
setIsNearBottom(true); setIsNearBottom(true);
} }
@@ -396,7 +468,7 @@ export function AgentStreamView({
(item: StreamItem, index: number, items: StreamItem[]) => { (item: StreamItem, index: number, items: StreamItem[]) => {
const handleInlineDetailsExpandedChange = (expanded: boolean) => { const handleInlineDetailsExpandedChange = (expanded: boolean) => {
if ( if (
!streamRenderStrategy.disableParentScrollOnInlineDetailsExpansion !streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()
) { ) {
return; return;
} }
@@ -660,6 +732,7 @@ export function AgentStreamView({
const showWorkingIndicator = agent.status === "running"; const showWorkingIndicator = agent.status === "running";
const showBottomBar = showWorkingIndicator; const showBottomBar = showWorkingIndicator;
const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList();
const listEdgeSlotComponent = useMemo(() => { const listEdgeSlotComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0; const hasPermissions = pendingPermissionItems.length > 0;
@@ -718,8 +791,8 @@ export function AgentStreamView({
client, client,
orderedStreamHead, orderedStreamHead,
renderStreamItemContent, renderStreamItemContent,
tightGap,
showBottomBar, showBottomBar,
tightGap,
]); ]);
const flatListExtraData = useMemo( const flatListExtraData = useMemo(
@@ -735,7 +808,7 @@ export function AgentStreamView({
] ]
); );
const listEdgeSlotProps = useMemo(() => { const listEdgeSlotProps = useMemo<StreamEdgeSlotProps>(() => {
if (!listEdgeSlotComponent) { if (!listEdgeSlotComponent) {
return {}; return {};
} }
@@ -781,10 +854,45 @@ export function AgentStreamView({
theme.colors.foregroundMuted, 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 ( return (
<ToolCallSheetProvider> <ToolCallSheetProvider>
<View style={stylesheet.container}> <View style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing> <MessageOuterSpacingProvider disableOuterSpacing>
{usesVirtualizedList ? (
<FlatList <FlatList
ref={flatListRef} ref={flatListRef}
data={flatListData} data={flatListData}
@@ -792,7 +900,7 @@ export function AgentStreamView({
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
testID="agent-chat-scroll" testID="agent-chat-scroll"
{...listEdgeSlotProps} {...listEdgeSlotProps}
contentContainerStyle={stylesheet.listContentContainer} contentContainerStyle={listContentContainerStyle}
style={stylesheet.list} style={stylesheet.list}
onLayout={handleListLayout} onLayout={handleListLayout}
onScroll={handleScroll} onScroll={handleScroll}
@@ -801,24 +909,50 @@ export function AgentStreamView({
ListEmptyComponent={listEmptyComponent} ListEmptyComponent={listEmptyComponent}
extraData={flatListExtraData} extraData={flatListExtraData}
maintainVisibleContentPosition={ maintainVisibleContentPosition={
streamRenderStrategy.maintainVisibleContentPosition streamRenderStrategy.getMaintainVisibleContentPosition()
} }
initialNumToRender={12} initialNumToRender={12}
windowSize={10} windowSize={10}
scrollEnabled={ scrollEnabled={streamScrollEnabled}
!streamRenderStrategy.disableParentScrollOnInlineDetailsExpansion ||
expandedInlineToolCallIds.size === 0
}
showsVerticalScrollIndicator={!showDesktopWebScrollbar} 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> </MessageOuterSpacingProvider>
<WebDesktopScrollbarOverlay <WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar} enabled={showDesktopWebScrollbar}
metrics={streamScrollbarMetrics} metrics={streamScrollbarMetrics}
inverted={streamRenderStrategy.overlayScrollbarInverted} inverted={streamRenderStrategy.getOverlayScrollbarInverted()}
onScrollToOffset={(nextOffset) => { onScrollToOffset={(nextOffset) => {
flatListRef.current?.scrollToOffset({ streamRenderStrategy.scrollToOffset({
refs: streamRenderRefs,
offset: nextOffset, offset: nextOffset,
animated: false, animated: false,
}); });
@@ -1380,6 +1514,10 @@ const stylesheet = StyleSheet.create((theme) => ({
md: theme.spacing[4], md: theme.spacing[4],
}, },
}, },
forwardListContentContainer: {
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
list: { list: {
flex: 1, flex: 1,
}, },
@@ -162,6 +162,8 @@ export function DictationOverlay({
<Pressable <Pressable
onPress={handleCancel} onPress={handleCancel}
disabled={actionsDisabled && !isFailed} disabled={actionsDisabled && !isFailed}
accessibilityRole="button"
accessibilityLabel="Cancel dictation"
style={[ style={[
overlayStyles.cancelButton, overlayStyles.cancelButton,
actionsDisabled && !isFailed && overlayStyles.buttonDisabled, actionsDisabled && !isFailed && overlayStyles.buttonDisabled,
@@ -213,6 +215,8 @@ export function DictationOverlay({
) : isFailed ? ( ) : isFailed ? (
<Pressable <Pressable
onPress={onRetry} onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Retry dictation"
style={[ style={[
overlayStyles.actionButton, overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white }, { backgroundColor: theme.colors.palette.white },
@@ -228,6 +232,8 @@ export function DictationOverlay({
<> <>
<Pressable <Pressable
onPress={onAccept} onPress={onAccept}
accessibilityRole="button"
accessibilityLabel="Insert transcription"
style={[ style={[
overlayStyles.actionButton, overlayStyles.actionButton,
{ backgroundColor: "rgba(255, 255, 255, 0.25)" }, { backgroundColor: "rgba(255, 255, 255, 0.25)" },
@@ -241,6 +247,8 @@ export function DictationOverlay({
</Pressable> </Pressable>
<Pressable <Pressable
onPress={onAcceptAndSend} onPress={onAcceptAndSend}
accessibilityRole="button"
accessibilityLabel="Insert transcription and send"
style={[ style={[
overlayStyles.actionButton, overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white }, { backgroundColor: theme.colors.palette.white },
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native"; import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
@@ -52,6 +53,7 @@ interface ExplorerSidebarProps {
export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) { export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; 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. // 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"; 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) { if (isMobile) {
return ( return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}> <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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Shortcut } from '@/components/ui/shortcut' import { Shortcut } from '@/components/ui/shortcut'
import type { MessageInputKeyboardActionKind } from '@/keyboard/actions' import type { MessageInputKeyboardActionKind } from '@/keyboard/actions'
import {
markScrollInvestigationEvent,
markScrollInvestigationRender,
} from '@/utils/scroll-jank-investigation'
export type ImageAttachment = AttachmentMetadata export type ImageAttachment = AttachmentMetadata
@@ -148,6 +152,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
ref ref
) { ) {
const { theme } = useUnistyles() const { theme } = useUnistyles()
const investigationComponentId = `MessageInput:${voiceServerId ?? 'unknown-server'}:${voiceAgentId ?? 'unknown-agent'}`
markScrollInvestigationRender(investigationComponentId)
const toast = useToast() const toast = useToast()
const voice = useVoiceOptional() const voice = useVoiceOptional()
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT) const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT)
@@ -247,7 +253,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (shouldAutoSend) { if (shouldAutoSend) {
const imageAttachments = images.length > 0 ? images : undefined const imageAttachments = images.length > 0 ? images : undefined
onSubmit({ text: nextValue, images: imageAttachments }) onSubmit({
text: nextValue,
images: imageAttachments,
forceSend: isAgentRunning || undefined,
})
} else { } else {
onChangeText(nextValue) 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) => { const handleDictationError = useCallback((error: Error) => {
@@ -302,8 +312,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onError: handleDictationError, onError: handleDictationError,
canStart: canStartDictation, canStart: canStartDictation,
canConfirm: canConfirmDictation, canConfirm: canConfirmDictation,
autoStopWhenHidden: autoStopWhenHidden: { isVisible: isScreenFocused },
Platform.OS === 'web' ? undefined : { isVisible: isScreenFocused },
enableDuration: true, enableDuration: true,
}) })
@@ -330,9 +339,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const startDictationIfAvailable = useCallback(async () => { const startDictationIfAvailable = useCallback(async () => {
if (dictationUnavailableMessage) { if (dictationUnavailableMessage) {
isDictatingRef.current = false
toast.error(dictationUnavailableMessage) toast.error(dictationUnavailableMessage)
return 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() await startDictation()
}, [dictationUnavailableMessage, startDictation, toast]) }, [dictationUnavailableMessage, startDictation, toast])
@@ -590,6 +603,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const shouldHandleDesktopSubmit = IS_WEB const shouldHandleDesktopSubmit = IS_WEB
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
markScrollInvestigationEvent(investigationComponentId, 'keyPress')
if (!shouldHandleDesktopSubmit) return if (!shouldHandleDesktopSubmit) return
// Allow parent to intercept key events (e.g., for autocomplete navigation) // 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 and interrupt'
: 'Send message' : 'Send message'
const handleInputChange = useCallback(
(nextValue: string) => {
markScrollInvestigationEvent(investigationComponentId, 'inputChange')
onChangeText(nextValue)
},
[investigationComponentId, onChangeText]
)
return ( return (
<View style={styles.container} testID="message-input-root"> <View style={styles.container} testID="message-input-root">
{/* Regular input */} {/* Regular input */}
@@ -672,7 +694,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<TextInput <TextInput
ref={textInputRef} ref={textInputRef}
value={value} value={value}
onChangeText={onChangeText} onChangeText={handleInputChange}
placeholder={placeholder} placeholder={placeholder}
placeholderTextColor={theme.colors.mutedForeground} placeholderTextColor={theme.colors.mutedForeground}
onFocus={() => { 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 { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
import { getMarkdownListMarker } from "@/utils/markdown-list"; import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
export type { InlinePathTarget } from "@/utils/inline-path"; export type { InlinePathTarget } from "@/utils/inline-path";
import { useToolCallSheet } from "./tool-call-sheet"; import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
@@ -1293,6 +1294,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
const detailContent = const detailContent =
hasDetailContent && isExpanded ? renderDetails?.() : null; hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailWrapperRef = useRef<View | null>(null); const detailWrapperRef = useRef<View | null>(null);
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${
testID ?? label
}`;
const nativeGradientIdRef = useRef( const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}` `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 }); node.addEventListener("wheel", stopWheelPropagation, { passive: true });
return () => { return () => {
markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelDetach");
node.removeEventListener("wheel", stopWheelPropagation); node.removeEventListener("wheel", stopWheelPropagation);
}; };
}, [isExpanded, hasDetailContent]); }, [hasDetailContent, isExpanded, wheelInvestigationComponentId]);
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({ const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shimmerTranslateX.value }], transform: [{ translateX: shimmerTranslateX.value }],
+75 -26
View File
@@ -17,6 +17,7 @@ import Svg, {
} from "react-native-svg"; } from "react-native-svg";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import type { ListTerminalsResponse } from "@server/shared/messages"; import type { ListTerminalsResponse } from "@server/shared/messages";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { import {
hasPendingTerminalModifiers, hasPendingTerminalModifiers,
@@ -557,29 +558,60 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
} }
}, []); }, []);
const flushPendingTerminalInput = useCallback(() => { const dispatchTerminalInputEntry = useCallback(
(entry: PendingTerminalInput): boolean => {
if (!client) { if (!client) {
return; return false;
} }
const currentStreamId = getCurrentActiveStreamId();
if (currentStreamId === null) { const terminalId = selectedTerminalIdRef.current;
return; 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; const queue = pendingTerminalInputRef.current;
if (queue.length === 0) { if (queue.length === 0) {
return; 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 (sentCount > 0) {
if (entry.type === "data") { queue.splice(0, sentCount);
client.sendTerminalStreamInput(currentStreamId, entry.data);
continue;
} }
client.sendTerminalStreamKey(currentStreamId, entry.input); }, [dispatchTerminalInputEntry]);
}
}, [client, getCurrentActiveStreamId]);
useEffect(() => { useEffect(() => {
flushPendingTerminalInput(); flushPendingTerminalInput();
@@ -625,8 +657,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
meta?: boolean; meta?: boolean;
} }
): boolean => { ): boolean => {
const currentStreamId = getCurrentActiveStreamId(); if (!client || !selectedTerminalIdRef.current) {
if (!client || currentStreamId === null) {
enqueuePendingTerminalInput({ enqueuePendingTerminalInput({
type: "key", type: "key",
input: { input: {
@@ -641,6 +672,16 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
} }
const normalizedKey = normalizeTerminalTransportKey(input.key); 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({ terminalDebugLog({
scope: "terminal-pane", scope: "terminal-pane",
event: "input:key:send", event: "input:key:send",
@@ -649,19 +690,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
ctrl: input.ctrl, ctrl: input.ctrl,
shift: input.shift, shift: input.shift,
alt: input.alt, alt: input.alt,
activeStreamId: currentStreamId, activeStreamId: getCurrentActiveStreamId(),
}, },
}); });
client.sendTerminalStreamKey(currentStreamId, { if (!dispatchTerminalInputEntry(pendingEntry)) {
key: normalizedKey, enqueuePendingTerminalInput(pendingEntry);
ctrl: input.ctrl, }
shift: input.shift,
alt: input.alt,
meta: input.meta,
});
return true; return true;
}, },
[client, enqueuePendingTerminalInput, getCurrentActiveStreamId] [
client,
dispatchTerminalInputEntry,
enqueuePendingTerminalInput,
getCurrentActiveStreamId,
]
); );
const handleTerminalData = useCallback( const handleTerminalData = useCallback(
@@ -705,7 +747,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
} }
} }
if (!client || currentStreamId === null) { if (!client || !selectedTerminalIdRef.current) {
enqueuePendingTerminalInput({ enqueuePendingTerminalInput({
type: "data", type: "data",
data, data,
@@ -721,11 +763,18 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
activeStreamId: currentStreamId, activeStreamId: currentStreamId,
}, },
}); });
client.sendTerminalStreamInput(currentStreamId, data); const pendingEntry: PendingTerminalInput = {
type: "data",
data,
};
if (!dispatchTerminalInputEntry(pendingEntry)) {
enqueuePendingTerminalInput(pendingEntry);
}
}, },
[ [
clearPendingModifiers, clearPendingModifiers,
client, client,
dispatchTerminalInputEntry,
getCurrentActiveStreamId, getCurrentActiveStreamId,
modifiers.alt, modifiers.alt,
modifiers.ctrl, modifiers.ctrl,
@@ -3,18 +3,45 @@ import { __private__ } from "./use-agent-initialization";
describe("useAgentInitialization timeline request policy", () => { describe("useAgentInitialization timeline request policy", () => {
it("uses canonical tail bootstrap when cursor is missing", () => { 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", direction: "tail",
limit: 200, limit: 200,
projection: "canonical", 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( expect(
__private__.buildInitialTimelineRequest({ __private__.buildInitialTimelineRequest({
cursor: {
epoch: "epoch-1", epoch: "epoch-1",
endSeq: 42, 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({ ).toEqual({
direction: "after", direction: "after",
@@ -23,4 +50,18 @@ describe("useAgentInitialization timeline request policy", () => {
projection: "canonical", 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 { useCallback } from "react";
import { Platform } from "react-native";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import type { import type {
DaemonClient, DaemonClient,
@@ -13,20 +14,28 @@ import {
} from "@/utils/agent-initialization"; } from "@/utils/agent-initialization";
const INIT_TIMEOUT_MS = 5 * 60_000; 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 = { type TimelineCursorState = {
epoch: string; epoch: string;
endSeq: number; endSeq: number;
}; };
type BuildInitialTimelineRequestInput = {
cursor: TimelineCursorState | undefined;
hasLocalTail: boolean;
initialTimelineLimit: number;
};
function buildInitialTimelineRequest( function buildInitialTimelineRequest(
cursor: TimelineCursorState | undefined input: BuildInitialTimelineRequestInput
): FetchAgentTimelineOptions { ): FetchAgentTimelineOptions {
if (!cursor) { const { cursor, hasLocalTail, initialTimelineLimit } = input;
if (!cursor || !hasLocalTail) {
return { return {
direction: "tail", direction: "tail",
limit: DEFAULT_INITIAL_TIMELINE_LIMIT, limit: initialTimelineLimit,
projection: "canonical", 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__ = { export const __private__ = {
buildInitialTimelineRequest, buildInitialTimelineRequest,
resolveInitialTimelineLimit,
}; };
export function useAgentInitialization({ export function useAgentInitialization({
@@ -76,7 +92,13 @@ export function useAgentInitialization({
const session = useSessionStore.getState().sessions[serverId]; const session = useSessionStore.getState().sessions[serverId];
const cursor = session?.agentTimelineCursor.get(agentId); 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 = const initRequestDirection =
timelineRequest.direction === "after" ? "after" : "tail"; timelineRequest.direction === "after" ? "after" : "tail";
@@ -128,9 +150,10 @@ export function useAgentInitialization({
try { try {
await client.refreshAgent(agentId); await client.refreshAgent(agentId);
const initialTimelineLimit = resolveInitialTimelineLimit();
await client.fetchAgentTimeline(agentId, { await client.fetchAgentTimeline(agentId, {
direction: "tail", direction: "tail",
limit: DEFAULT_INITIAL_TIMELINE_LIMIT, limit: initialTimelineLimit,
projection: "canonical", projection: "canonical",
}); });
} catch (error) { } catch (error) {
@@ -57,6 +57,92 @@ const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => {
return out; 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 int16ToBase64 = (pcm: Int16Array): string => {
const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength); const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
let binary = ""; let binary = "";
@@ -359,16 +445,23 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
} }
} }
if (mode === "recorder" && recorder.stoppedPromise && context) { if (mode === "recorder" && recorder.stoppedPromise) {
try { try {
const blob = await recorder.stoppedPromise; const blob = await recorder.stoppedPromise;
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();
if (arrayBuffer.byteLength > 0) { 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 decoded = await decodeAudioData(context, arrayBuffer);
const floatPcm = decoded.getChannelData(0); const floatPcm = decoded.getChannelData(0);
const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000); const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000);
emitPcmSegments(pcm16); emitPcmSegments(pcm16);
} }
}
} catch (err) { } catch (err) {
onErrorRef.current?.(err instanceof Error ? err : new Error(String(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 [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<DictationStatus>("idle"); const [status, setStatus] = useState<DictationStatus>("idle");
const latestPartialTranscriptRef = useRef("");
const onTranscriptRef = useRef(onTranscript); const onTranscriptRef = useRef(onTranscript);
useEffect(() => { useEffect(() => {
@@ -130,6 +131,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
const clearStreamingState = useCallback(() => { const clearStreamingState = useCallback(() => {
senderRef.current?.clearAll(); senderRef.current?.clearAll();
latestPartialTranscriptRef.current = "";
setPartialTranscript(""); setPartialTranscript("");
}, []); }, []);
@@ -181,6 +183,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
return; return;
} }
const next = message.payload.text ?? ""; const next = message.payload.text ?? "";
latestPartialTranscriptRef.current = next;
setPartialTranscript(next); setPartialTranscript(next);
onPartialTranscriptRef.current?.(next, { requestId: generateMessageId() }); onPartialTranscriptRef.current?.(next, { requestId: generateMessageId() });
}); });
@@ -203,13 +206,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
(text: string, requestId: string) => { (text: string, requestId: string) => {
setIsProcessing(false); setIsProcessing(false);
isProcessingRef.current = false; isProcessingRef.current = false;
setPartialTranscript("");
setDuration(0); setDuration(0);
setStatus("idle"); setStatus("idle");
const transcriptText = (
text.trim().length > 0 ? text.trim() : latestPartialTranscriptRef.current.trim()
);
clearStreamingState(); clearStreamingState();
const transcriptText = text.trim();
if (!transcriptText) { if (!transcriptText) {
return; return;
} }
@@ -261,14 +265,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
try { try {
await audio.start(); await audio.start();
if (client?.isConnected) {
await startNewStream("start");
}
isRecordingRef.current = true; isRecordingRef.current = true;
setIsRecording(true); setIsRecording(true);
if (enableDuration) { if (enableDuration) {
startDurationTracking(); startDurationTracking();
} }
if (client?.isConnected) {
await startNewStream("start");
}
} catch (err) { } catch (err) {
await audio.stop().catch(() => undefined); await audio.stop().catch(() => undefined);
stopDurationTracking(); stopDurationTracking();
@@ -745,6 +745,7 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls[0]).toEqual({ expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { labels: { ui: "true" } }, filter: { labels: { ui: "true" } },
subscribe: { subscriptionId: "app:srv_test" }, subscribe: { subscriptionId: "app:srv_test" },
page: { limit: 200 },
}); });
const snapshot = store.getSnapshot(host.serverId); const snapshot = store.getSnapshot(host.serverId);
@@ -1088,8 +1088,9 @@ function AgentScreenContent({
/> />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content"> <DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
<View style={styles.menuMetaContainer}> <View style={styles.menuMetaContainer} testID="agent-details-sheet">
<Pressable <Pressable
testID="agent-details-agent-id"
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
styles.menuMetaRow, styles.menuMetaRow,
(hovered || pressed) && styles.menuMetaRowActive, (hovered || pressed) && styles.menuMetaRowActive,
@@ -1102,6 +1103,7 @@ function AgentScreenContent({
Directory Directory
</Text> </Text>
<Text <Text
testID="agent-details-agent-id-value"
style={styles.menuMetaValue} style={styles.menuMetaValue}
numberOfLines={1} numberOfLines={1}
ellipsizeMode="middle" ellipsizeMode="middle"
@@ -1179,6 +1181,7 @@ function AgentScreenContent({
</Pressable> </Pressable>
<Pressable <Pressable
testID="agent-details-persistence-session-id"
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
styles.menuMetaRow, styles.menuMetaRow,
providerSessionId && (hovered || pressed) && styles.menuMetaRowActive, providerSessionId && (hovered || pressed) && styles.menuMetaRowActive,
@@ -1192,6 +1195,7 @@ function AgentScreenContent({
{providerLabel} ID {providerLabel} ID
</Text> </Text>
<Text <Text
testID="agent-details-persistence-session-id-value"
style={[styles.menuMetaValue, !providerSessionId && styles.menuMetaValueError]} style={[styles.menuMetaValue, !providerSessionId && styles.menuMetaValueError]}
numberOfLines={1} numberOfLines={1}
ellipsizeMode="middle" ellipsizeMode="middle"
@@ -1209,6 +1209,7 @@ function DraftAgentScreenContent({
icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />} icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false} showLabel={false}
valueEllipsizeMode="middle" valueEllipsizeMode="middle"
testID="worktree-select-trigger"
/> />
{worktreeMode === 'create' ? ( {worktreeMode === 'create' ? (
<FormSelectTrigger <FormSelectTrigger
@@ -1221,6 +1222,7 @@ function DraftAgentScreenContent({
disabled={repoInfoStatus === 'loading'} disabled={repoInfoStatus === 'loading'}
icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />} icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false} showLabel={false}
testID="worktree-base-branch-trigger"
/> />
) : null} ) : null}
</View> </View>
@@ -15,6 +15,7 @@ interface KeyboardShortcutsState {
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */ /** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[]; sidebarShortcutAgentKeys: string[];
messageInputActionRequest: MessageInputActionRequest | null; messageInputActionRequest: MessageInputActionRequest | null;
nextMessageInputActionRequestId: number;
setCommandCenterOpen: (open: boolean) => void; setCommandCenterOpen: (open: boolean) => void;
setShortcutsDialogOpen: (open: boolean) => void; setShortcutsDialogOpen: (open: boolean) => void;
@@ -38,6 +39,7 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
cmdOrCtrlDown: false, cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [], sidebarShortcutAgentKeys: [],
messageInputActionRequest: null, messageInputActionRequest: null,
nextMessageInputActionRequestId: 1,
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }), setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
@@ -47,9 +49,11 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }), resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
requestMessageInputAction: ({ agentKey, kind }) => { requestMessageInputAction: ({ agentKey, kind }) => {
const previous = get().messageInputActionRequest; const id = get().nextMessageInputActionRequestId;
const id = (previous?.id ?? 0) + 1; set({
set({ messageInputActionRequest: { id, agentKey, kind } }); messageInputActionRequest: { id, agentKey, kind },
nextMessageInputActionRequestId: id + 1,
});
}, },
clearMessageInputActionRequest: (id) => { clearMessageInputActionRequest: (id) => {
const current = get().messageInputActionRequest; 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", () => ({ vi.mock("@server/client/daemon-client", () => ({
DaemonClient: daemonClientMock.MockDaemonClient, DaemonClient: daemonClientMock.MockDaemonClient,
})); }));
vi.mock("./client-id", () => ({
getOrCreateClientId: clientIdMock.getOrCreateClientId,
}));
describe("test-daemon-connection probe client identity", () => { describe("test-daemon-connection probe client identity", () => {
beforeEach(() => { beforeEach(() => {
daemonClientMock.createdConfigs.length = 0; 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"); const mod = await import("./test-daemon-connection");
await mod.measureConnectionLatency({ await mod.measureConnectionLatency({
@@ -72,8 +81,8 @@ describe("test-daemon-connection probe client identity", () => {
}); });
const [first, second] = daemonClientMock.createdConfigs; const [first, second] = daemonClientMock.createdConfigs;
expect(first?.clientId).toMatch(/^cid_probe_/); expect(first?.clientId).toBe("cid_shared_probe_test");
expect(second?.clientId).toMatch(/^cid_probe_/); expect(second?.clientId).toBe("cid_shared_probe_test");
expect(first?.clientId).not.toBe(second?.clientId); expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
}); });
}); });
@@ -1,18 +1,9 @@
import { DaemonClient } from "@server/client/daemon-client"; import { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClientConfig } from "@server/client/daemon-client"; import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { HostConnection } from "@/contexts/daemon-registry-context"; import type { HostConnection } from "@/contexts/daemon-registry-context";
import { getOrCreateClientId } from "./client-id";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport"; 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 { function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
@@ -54,7 +45,7 @@ async function buildClientConfig(
connection: HostConnection, connection: HostConnection,
serverId?: string serverId?: string
): Promise<DaemonClientConfig> { ): Promise<DaemonClientConfig> {
const clientId = createProbeClientId(); const clientId = await getOrCreateClientId();
const tauriTransportFactory = createTauriWebSocketTransportFactory(); const tauriTransportFactory = createTauriWebSocketTransportFactory();
const base = { const base = {
clientId, 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", "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
"prepack": "npm run build", "prepack": "npm run build",
"typecheck": "tsc --noEmit", "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" "test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
}, },
"dependencies": { "dependencies": {
+101 -17
View File
@@ -1,8 +1,9 @@
import { spawn, spawnSync } from 'node:child_process' 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 { createRequire } from 'node:module'
import path from 'node:path' import path from 'node:path'
import { loadConfig, resolvePaseoHome } from '@getpaseo/server' import { loadConfig, resolvePaseoHome } from '@getpaseo/server'
import { tryConnectToDaemon } from '../../utils/client.js'
export interface DaemonStartOptions { export interface DaemonStartOptions {
port?: string port?: string
@@ -219,19 +220,65 @@ function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
} }
} }
async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> { function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
const deadline = Date.now() + timeoutMs 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) { while (Date.now() < deadline) {
if (!isProcessRunning(pid)) { if (!isProcessRunning(pid)) {
return true return true
} }
await sleep(PID_POLL_INTERVAL_MS) await sleep(PID_POLL_INTERVAL_MS)
} }
return !isProcessRunning(pid) 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 { export function resolveLocalPaseoHome(home?: string): string {
return resolvePaseoHome(envWithHome(home)) return resolvePaseoHome(envWithHome(home))
} }
@@ -384,6 +431,41 @@ export function startLocalDaemonForeground(options: DaemonStartOptions): number
return result.status ?? 1 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( export async function stopLocalDaemon(
options: StopLocalDaemonOptions = {} options: StopLocalDaemonOptions = {}
): Promise<StopLocalDaemonResult> { ): Promise<StopLocalDaemonResult> {
@@ -405,7 +487,12 @@ export async function stopLocalDaemon(
} }
const pid = state.pidInfo.pid 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) { if (!signaled) {
return { return {
action: 'not_running', action: 'not_running',
@@ -415,18 +502,13 @@ export async function stopLocalDaemon(
message: 'Daemon process was already stopped', message: 'Daemon process was already stopped',
} }
} }
}
let forced = false let stopped = await waitForPidExit(pid, timeoutMs)
let stopped = await waitForExit(pid, timeoutMs)
if (!stopped && options.force) { if (!stopped && options.force) {
forced = true forced = true
const killSent = signalProcess(pid, 'SIGKILL') signalProcessGroupSafely(pid, 'SIGKILL')
if (killSent) { stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS)
stopped = await waitForExit(pid, KILL_TIMEOUT_MS)
} else {
stopped = true
}
} }
if (!stopped) { if (!stopped) {
@@ -435,13 +517,15 @@ export async function stopLocalDaemon(
) )
} }
rmSync(state.pidPath, { force: true })
return { return {
action: 'stopped', action: 'stopped',
home: state.home, home: state.home,
pid, pid,
forced, 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',
} }
} }
+149 -68
View File
@@ -4,7 +4,8 @@
* Phase 15: Provider Command Tests * Phase 15: Provider Command Tests
* *
* Tests provider commands for listing providers and models. * 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: * Tests:
* - provider --help shows subcommands * - provider --help shows subcommands
@@ -19,38 +20,73 @@
*/ */
import assert from 'node:assert' import assert from 'node:assert'
import { $ } from 'zx' import { createE2ETestContext } from './helpers/test-daemon.ts'
$.verbose = false
console.log('=== Provider Commands ===\n') console.log('=== Provider Commands ===\n')
// Test 1: provider --help shows subcommands 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') 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.strictEqual(result.exitCode, 0, 'provider --help should exit 0')
assert(result.stdout.includes('ls'), 'help should mention ls') assert(result.stdout.includes('ls'), 'help should mention ls')
assert(result.stdout.includes('models'), 'help should mention models') assert(result.stdout.includes('models'), 'help should mention models')
console.log('✓ provider --help shows subcommands\n') console.log('✓ provider --help shows subcommands\n')
} }
// Test 2: provider ls lists all providers // Test 2: provider ls lists all providers
{ {
console.log('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.strictEqual(result.exitCode, 0, 'provider ls should exit 0')
assert(result.stdout.includes('claude'), 'output should include claude') assert(result.stdout.includes('claude'), 'output should include claude')
assert(result.stdout.includes('codex'), 'output should include codex') assert(result.stdout.includes('codex'), 'output should include codex')
assert(result.stdout.includes('opencode'), 'output should include opencode') assert(result.stdout.includes('opencode'), 'output should include opencode')
assert(result.stdout.includes('available'), 'output should show available status') assert(result.stdout.includes('available'), 'output should show available status')
console.log('✓ provider ls lists all providers\n') console.log('✓ provider ls lists all providers\n')
} }
// Test 3: provider ls --json outputs valid JSON // Test 3: provider ls --json outputs valid JSON
{ {
console.log('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') assert.strictEqual(result.exitCode, 0, 'should exit 0')
const data = JSON.parse(result.stdout.trim()) const data = JSON.parse(result.stdout.trim())
assert(Array.isArray(data), 'output should be an array') assert(Array.isArray(data), 'output should be an array')
@@ -59,12 +95,12 @@ console.log('=== Provider Commands ===\n')
assert(data.some((p: { provider: string }) => p.provider === 'codex'), 'should include codex') assert(data.some((p: { provider: string }) => p.provider === 'codex'), 'should include codex')
assert(data.some((p: { provider: string }) => p.provider === 'opencode'), 'should include opencode') assert(data.some((p: { provider: string }) => p.provider === 'opencode'), 'should include opencode')
console.log('✓ provider ls --json outputs valid JSON\n') console.log('✓ provider ls --json outputs valid JSON\n')
} }
// Test 4: provider ls --quiet outputs provider names only // Test 4: provider ls --quiet outputs provider names only
{ {
console.log('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') assert.strictEqual(result.exitCode, 0, 'should exit 0')
const lines = result.stdout.trim().split('\n') const lines = result.stdout.trim().split('\n')
assert.strictEqual(lines.length, 3, 'should have 3 lines') assert.strictEqual(lines.length, 3, 'should have 3 lines')
@@ -72,44 +108,68 @@ console.log('=== Provider Commands ===\n')
assert(lines.includes('codex'), 'should include codex') assert(lines.includes('codex'), 'should include codex')
assert(lines.includes('opencode'), 'should include opencode') assert(lines.includes('opencode'), 'should include opencode')
console.log('✓ provider ls --quiet outputs provider names only\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') console.log('Test 5: provider models claude lists canonical model aliases')
const result = await $`npx paseo provider models claude`.nothrow() const data = await runProviderModelsJson('claude')
assert.strictEqual(result.exitCode, 0, 'provider models claude should exit 0') assert.strictEqual(data.length, 3, 'should have exactly 3 claude models')
assert(result.stdout.includes('claude-sonnet-4-20250514'), 'output should include claude-sonnet-4') const byId = new Map(data.map((model) => [model.id, model]))
assert(result.stdout.includes('claude-opus-4-20250514'), 'output should include claude-opus-4') const ids = [...byId.keys()].sort()
assert(result.stdout.includes('claude-3-5-haiku-20241022'), 'output should include claude-haiku') assert.deepStrictEqual(ids, ['default', 'haiku', 'opus'], 'claude IDs should be default/opus/haiku')
console.log('✓ provider models claude lists claude models\n') 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') console.log('Test 6: provider models codex includes concrete codex model IDs')
const result = await $`npx paseo provider models codex`.nothrow() const data = await runProviderModelsJson('codex')
assert.strictEqual(result.exitCode, 0, 'provider models codex should exit 0') assert(data.length >= 6, 'codex model list should include current codex lineup')
assert(result.stdout.includes('o3-mini'), 'output should include o3-mini') const ids = data.map((m) => m.id)
assert(result.stdout.includes('o4-mini'), 'output should include o4-mini') assert.strictEqual(new Set(ids).size, ids.length, 'codex model IDs should be unique')
console.log('✓ provider models codex lists codex models\n') 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') console.log('Test 7: provider models opencode returns namespaced model IDs')
const result = await $`npx paseo provider models opencode`.nothrow() const data = await runProviderModelsJson('opencode')
assert.strictEqual(result.exitCode, 0, 'provider models opencode should exit 0') assert(data.length >= 3, 'opencode model list should not be empty')
// opencode supports both claude and codex models const ids = data.map((m) => m.id)
assert(result.stdout.includes('claude-sonnet-4-20250514'), 'output should include claude models') assert(data.every((m) => m.id.includes('/')), 'opencode model IDs should be provider-namespaced')
assert(result.stdout.includes('o3-mini'), 'output should include codex models') assert(ids.includes('opencode/gpt-5-nano'), 'opencode output should include opencode/gpt-5-nano')
console.log('✓ provider models opencode lists opencode models\n') 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 // Test 8: provider models unknown fails with error
{ {
console.log('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') assert.notStrictEqual(result.exitCode, 0, 'should fail for unknown provider')
const output = result.stdout + result.stderr const output = result.stdout + result.stderr
assert( assert(
@@ -117,31 +177,52 @@ console.log('=== Provider Commands ===\n')
'error should mention unknown provider' 'error should mention unknown provider'
) )
console.log('✓ provider models unknown fails with error\n') console.log('✓ provider models unknown fails with error\n')
} }
// Test 9: provider models --json outputs valid JSON // Test 9: provider models --json outputs valid JSON
{ {
console.log('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() const data = await runProviderModelsJson('claude')
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const data = JSON.parse(result.stdout.trim())
assert(Array.isArray(data), 'output should be an array') assert(Array.isArray(data), 'output should be an array')
assert.strictEqual(data.length, 3, 'should have 3 models for claude') assert.strictEqual(data.length, 3, 'should have exactly 3 models for claude')
assert(data.every((m: { model: string; id: string }) => m.model && m.id), 'each model should have name and id') 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') console.log('✓ provider models --json outputs valid JSON\n')
} }
// Test 10: provider models --quiet outputs model IDs only // Test 10: provider models --quiet outputs model IDs only
{ {
console.log('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') 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.strictEqual(lines.length, 3, 'should have 3 lines')
assert(lines.includes('claude-sonnet-4-20250514'), 'should include claude-sonnet-4') assert.deepStrictEqual(
assert(lines.includes('claude-opus-4-20250514'), 'should include claude-opus-4') [...lines].sort(),
assert(lines.includes('claude-3-5-haiku-20241022'), 'should include claude-haiku') [...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') console.log('✓ provider models --quiet outputs model IDs only\n')
}
} finally {
await ctx.stop()
} }
console.log('=== All provider tests passed ===') console.log('=== All provider tests passed ===')
+15 -12
View File
@@ -8,7 +8,7 @@
* - Help and argument parsing * - Help and argument parsing
* - Validation for required update fields * - Validation for required update fields
* - Graceful daemon connection errors * - Graceful daemon connection errors
* - Top-level alias support (`paseo update`) * - Top-level daemon update alias behavior (`paseo update`)
*/ */
import assert from 'node:assert' import assert from 'node:assert'
@@ -16,13 +16,14 @@ import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises' import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import { join } from 'path' import { join } from 'path'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false $.verbose = false
console.log('=== Agent Update Command Tests ===\n') console.log('=== Agent Update Command Tests ===\n')
// Get random port that's definitely not in use (never 6767) // Resolve an available local port so daemon-not-running checks stay deterministic.
const port = 10000 + Math.floor(Math.random() * 50000) const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-')) const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try { try {
@@ -104,25 +105,27 @@ try {
console.log('✓ agent --help shows update subcommand\n') 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() const result = await $`npx paseo update --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'update --help should exit 0') assert.strictEqual(result.exitCode, 0, 'update --help should exit 0')
assert(result.stdout.includes('--name'), 'help should mention --name flag') assert(result.stdout.includes('--home'), 'help should mention --home flag')
assert(result.stdout.includes('--label'), 'help should mention --label flag') assert(result.stdout.includes('--yes'), 'help should mention --yes flag')
console.log('✓ top-level update --help works\n') 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 = 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 const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept top-level update flags') assert(!output.includes('unknown option'), 'should accept top-level update flags')
assert(!output.includes('error: option'), 'should not have option parsing error') 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 { } finally {
// Clean up temp directory // 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 { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { $ } from 'zx' import { $ } from 'zx'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false $.verbose = false
function randomPort(): number {
return 10000 + Math.floor(Math.random() * 50000)
}
console.log('=== Onboarding Command ===\n') console.log('=== Onboarding Command ===\n')
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-onboard-home-')) const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-onboard-home-'))
const port = randomPort() const port = await getAvailablePort()
try { try {
console.log('Test 1: `paseo` runs blocking onboarding and prints pairing info') console.log('Test 1: `paseo` runs blocking onboarding and prints pairing info')
const onboard = 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('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('Pairing link'), 'onboard output should include pairing link header')
assert(onboard.stdout.includes('#offer='), 'onboard output should include pairing offer URL') 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') assert(onboard.stdout.includes(join(paseoHome, 'daemon.log')), 'onboard output should include daemon log path')
const status = 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.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`)
assert(status.stdout.includes('running'), 'daemon should be running when onboarding exits') assert(status.stdout.includes('running'), 'daemon should be running when onboarding exits')
console.log('✓ onboarding prints pairing info and waits for daemon readiness\n') 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') console.log('✓ non-interactive run persisted voice disabled choices\n')
} finally { } 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 }) 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 { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { $ } from 'zx' import { $ } from 'zx'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false $.verbose = false
const pollIntervalMs = 100 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> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms))
@@ -68,7 +74,7 @@ type DaemonStatus = {
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> { async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result = 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) { if (result.exitCode !== 0) {
return { status: null, pid: null } return { status: null, pid: null }
} }
@@ -104,7 +110,7 @@ async function waitFor(
console.log('=== Daemon Stop (supervisor regression) ===\n') 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 paseoHome = await mkdtemp(join(tmpdir(), 'paseo-stop-supervisor-'))
const cliRoot = join(import.meta.dirname, '..') const cliRoot = join(import.meta.dirname, '..')
@@ -118,6 +124,7 @@ try {
cwd: cliRoot, cwd: cliRoot,
env: { env: {
...process.env, ...process.env,
...testEnv,
PASEO_HOME: paseoHome, PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`, PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: 'false', PASEO_RELAY_ENABLED: 'false',
@@ -159,7 +166,7 @@ try {
console.log('Test 2: `paseo daemon stop` should stop without respawn') console.log('Test 2: `paseo daemon stop` should stop without respawn')
const stopResult = 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}`) assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`)
const stopJson = JSON.parse(stopResult.stdout) as { action?: unknown } const stopJson = JSON.parse(stopResult.stdout) as { action?: unknown }
assert.strictEqual(stopJson.action, 'stopped', 'stop should report stopped action') assert.strictEqual(stopJson.action, 'stopped', 'stop should report stopped action')
@@ -189,6 +196,14 @@ try {
const statusAfterStop = await readDaemonStatus(paseoHome) const statusAfterStop = await readDaemonStatus(paseoHome)
assert.strictEqual(statusAfterStop.status, 'stopped', 'daemon should remain stopped after stop command') 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') console.log('✓ stop leaves supervised daemon stopped (no respawn)\n')
} finally { } finally {
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) { 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 }) 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 * Test Daemon Helper
* *
* Provides utilities for launching real Paseo daemons in E2E tests. * 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): * 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 * 2. Protocol: WebSocket ONLY - daemon has no HTTP endpoints
* 3. Temp dirs: Create temp directories for PASEO_HOME and agent --cwd * 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 * 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 { tmpdir } from 'os'
import { join } from 'path' import { join } from 'path'
import { ChildProcess, spawn } from 'child_process' import { ChildProcess, spawn } from 'child_process'
import { getAvailablePort } from './network.ts'
export interface TestDaemonContext { export interface TestDaemonContext {
/** Random port for test daemon (never 6767) */ /** Available local port for test daemon (never 6767) */
port: number port: number
/** WebSocket URL for connecting to daemon */ /** WebSocket URL for connecting to daemon */
wsUrl: string wsUrl: string
@@ -35,6 +36,117 @@ export interface TestDaemonContext {
stop: () => Promise<void> 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 * Generate a random port for test daemon
* Uses range 20000-30000 to avoid conflicts * Uses range 20000-30000 to avoid conflicts
@@ -73,7 +185,7 @@ async function waitForDaemonReady(
const { exitCode } = await runPaseoCli( const { exitCode } = await runPaseoCli(
{ {
port, port,
wsUrl: `ws://127.0.0.1:${port}`, wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
paseoHome: '', paseoHome: '',
workDir: '', workDir: '',
process: null, process: null,
@@ -110,13 +222,13 @@ export async function startTestDaemon(options?: {
workDir?: string workDir?: string
timeout?: number timeout?: number
}): Promise<TestDaemonContext> { }): Promise<TestDaemonContext> {
const port = options?.port ?? getRandomPort() const port = options?.port ?? await getAvailablePort()
const { paseoHome, workDir } = options?.paseoHome && options?.workDir const { paseoHome, workDir } = options?.paseoHome && options?.workDir
? { paseoHome: options.paseoHome, workDir: options.workDir } ? { paseoHome: options.paseoHome, workDir: options.workDir }
: await createTempDirs() : await createTempDirs()
const timeout = options?.timeout ?? 30000 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 // Find the CLI entry point - use the source file directly with tsx
const cliDir = join(import.meta.dirname, '..', '..') const cliDir = join(import.meta.dirname, '..', '..')
@@ -126,43 +238,30 @@ export async function startTestDaemon(options?: {
const daemonProcess = spawn('npx', ['tsx', cliSrcPath, 'daemon', 'start', '--foreground'], { const daemonProcess = spawn('npx', ['tsx', cliSrcPath, 'daemon', 'start', '--foreground'], {
env: { env: {
...process.env, ...process.env,
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOME: paseoHome, PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`, PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
// Disable relay for tests
PASEO_RELAY_ENABLED: 'false',
// Force no TTY to prevent QR code output // Force no TTY to prevent QR code output
CI: 'true', CI: 'true',
}, },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
detached: false, detached: process.platform !== 'win32',
}) })
let stdout = '' const stdout = createOutputCapture()
let stderr = '' const stderr = createOutputCapture()
daemonProcess.stdout?.on('data', (data) => { daemonProcess.stdout?.on('data', (data) => {
stdout += data.toString() appendOutputCapture(stdout, data)
}) })
daemonProcess.stderr?.on('data', (data) => { daemonProcess.stderr?.on('data', (data) => {
stderr += data.toString() appendOutputCapture(stderr, data)
}) })
const cleanup = async () => { const cleanup = async () => {
if (daemonProcess && !daemonProcess.killed) { if (daemonProcess) {
daemonProcess.kill('SIGTERM') await terminateProcessTree(daemonProcess, 5000)
// Wait for process to exit
await new Promise<void>((resolve) => {
const timeoutId = setTimeout(() => {
daemonProcess.kill('SIGKILL')
resolve()
}, 5000)
daemonProcess.on('exit', () => {
clearTimeout(timeoutId)
resolve()
})
})
} }
// Clean up temp directories // Clean up temp directories
@@ -191,8 +290,9 @@ export async function startTestDaemon(options?: {
daemonProcess.on('exit', (code) => { daemonProcess.on('exit', (code) => {
if (code !== 0 && code !== null) { if (code !== 0 && code !== null) {
console.error(`Daemon process exited with code ${code}`) console.error(`Daemon process exited with code ${code}`)
if (stderr) { const stderrText = formatOutputCapture(stderr)
console.error('Daemon stderr:', 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 // Daemon failed to start - clean up and rethrow
await cleanup() await cleanup()
const message = err instanceof Error ? err.message : String(err) 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 return ctx
@@ -245,26 +347,30 @@ export async function runPaseoCli(
const proc = spawn('npx', ['tsx', cliSrcPath, ...args], { const proc = spawn('npx', ['tsx', cliSrcPath, ...args], {
env: { env: {
...process.env, ...process.env,
PASEO_HOST: `localhost:${ctx.port}`, ...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
PASEO_HOME: ctx.paseoHome, PASEO_HOME: ctx.paseoHome,
}, },
cwd, cwd,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
}) })
let stdout = '' const stdout = createOutputCapture()
let stderr = '' const stderr = createOutputCapture()
proc.stdout?.on('data', (data) => { proc.stdout?.on('data', (data) => {
stdout += data.toString() appendOutputCapture(stdout, data)
}) })
proc.stderr?.on('data', (data) => { proc.stderr?.on('data', (data) => {
stderr += data.toString() appendOutputCapture(stderr, data)
}) })
const timeoutId = setTimeout(() => { 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(' ')}`)) reject(new Error(`CLI command timed out after ${timeout}ms: paseo ${args.join(' ')}`))
}, timeout) }, timeout)
@@ -272,8 +378,8 @@ export async function runPaseoCli(
clearTimeout(timeoutId) clearTimeout(timeoutId)
resolve({ resolve({
exitCode: code ?? 1, exitCode: code ?? 1,
stdout, stdout: formatOutputCapture(stdout),
stderr, stderr: formatOutputCapture(stderr),
}) })
}) })
+70 -4
View File
@@ -8,11 +8,31 @@
*/ */
import { $ } from 'zx' import { $ } from 'zx'
import { readdir } from 'fs/promises' import { readdir, writeFile } from 'fs/promises'
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.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 $.verbose = false
@@ -26,8 +46,29 @@ const testFiles = files
.sort() .sort()
if (testFiles.length === 0) { if (testFiles.length === 0) {
console.log('⚠️ No test files found') console.log('❌ No test files found')
process.exit(0) 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`) console.log(`Found ${testFiles.length} test file(s):\n`)
@@ -49,7 +90,7 @@ for (const testFile of testFiles) {
console.log('─'.repeat(50)) console.log('─'.repeat(50))
try { 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) { if (result.exitCode === 0) {
console.log(`\n✅ ${testName} PASSED`) console.log(`\n✅ ${testName} PASSED`)
passed++ passed++
@@ -89,4 +130,29 @@ if (failures.length > 0) {
} }
console.log() 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) 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 { tmpdir } from 'os'
import { join } from 'path' 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 { export interface TestContext {
/** Random port for test daemon (never 6767) */ /** Random port for test daemon (never 6767) */
port: number port: number
@@ -72,7 +105,7 @@ export async function startDaemon(
paseoHome: string paseoHome: string
): Promise<ProcessPromise> { ): Promise<ProcessPromise> {
$.verbose = false $.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 return daemon
} }
@@ -86,14 +119,20 @@ export async function createTestContext(): Promise<TestContext> {
// Helper to run CLI commands against test daemon // Helper to run CLI commands against test daemon
const paseo = (args: string[]): ProcessPromise => { const paseo = (args: string[]): ProcessPromise => {
$.verbose = false $.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 // Cleanup function
const cleanup = async (): Promise<void> => { const cleanup = async (): Promise<void> => {
if (ctx.daemon) { 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() ctx.daemon.kill()
} }
}
await rm(paseoHome, { recursive: true, force: true }) await rm(paseoHome, { recursive: true, force: true })
await rm(workDir, { 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 { WebSocket } from "ws";
import net from "node:net"; import net from "node:net";
import { spawn, type ChildProcess } from "node:child_process"; import { spawn, type ChildProcess } from "node:child_process";
import { createRequire } from "node:module";
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import { import {
generateKeyPair, generateKeyPair,
@@ -14,6 +15,9 @@ import {
const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0"); const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0");
const shouldRunRelayE2e = process.env.FORCE_RELAY_E2E === "1" || nodeMajor < 25; 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> { async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => { 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(); const start = Date.now();
while (Date.now() - start < timeout) { while (Date.now() - start < timeout) {
assertRelayStillRunning(relayProcess);
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, "127.0.0.1", () => { const socket = net.connect(port, "127.0.0.1", () => {
@@ -43,15 +81,20 @@ async function waitForServer(port: number, timeout = 15000): Promise<void> {
}); });
return; return;
} catch { } catch {
await new Promise((r) => setTimeout(r, 100)); await sleep(100);
} }
} }
throw new Error(`Server did not start on port ${port} within ${timeout}ms`); 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(); const start = Date.now();
while (Date.now() - start < timeout) { while (Date.now() - start < timeout) {
assertRelayStillRunning(relayProcess);
const serverId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; 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 probeUrl = `ws://127.0.0.1:${port}/ws?serverId=${serverId}&role=server&v=2`;
const opened = await new Promise<boolean>((resolve) => { const opened = await new Promise<boolean>((resolve) => {
@@ -73,37 +116,44 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
if (opened) { if (opened) {
return; 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`); 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", () => { (shouldRunRelayE2e ? describe : describe.skip)("E2E Relay with E2EE", () => {
let relayPort: number; let relayPort: number;
let relayProcess: ChildProcess | null = null; let relayProcess: ChildProcess | null = null;
beforeAll(async () => { beforeAll(async () => {
relayPort = await getAvailablePort(); relayPort = await getAvailablePort();
relayProcess = spawn( relayProcess = spawnRelayDevServer(relayPort);
"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.stdout?.on("data", (data: Buffer) => { relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data.toString().split("\n").filter((l) => l.trim()); 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); try {
await waitForRelayWebSocketReady(relayPort, 60000); 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 () => { afterAll(async () => {
if (relayProcess) { if (relayProcess) {
relayProcess.kill("SIGTERM"); await stopRelayProcess(relayProcess);
relayProcess = null; relayProcess = null;
} }
}); }, SHUTDOWN_TIMEOUT_MS);
it("full flow: daemon and client exchange encrypted messages through relay", { timeout: 90_000 }, async () => { it("full flow: daemon and client exchange encrypted messages through relay", { timeout: 90_000 }, async () => {
const serverId = "test-session-" + Date.now(); const serverId = "test-session-" + Date.now();
+11 -4
View File
@@ -45,12 +45,19 @@
"speech:download": "tsx scripts/download-speech-models.ts", "speech:download": "tsx scripts/download-speech-models.ts",
"speech:tts:matrix": "tsx scripts/generate-sherpa-tts-matrix.ts", "speech:tts:matrix": "tsx scripts/generate-sherpa-tts-matrix.ts",
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.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:watch": "vitest",
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
"test:e2e": "playwright test", "test:e2e": "vitest run e2e.test.ts --maxWorkers=1 --minWorkers=1 --exclude \"**/*.real.e2e.test.ts\" --exclude \"**/*.local.e2e.test.ts\"",
"test:e2e:ui": "playwright test --ui", "test:e2e:all": "vitest run e2e.test.ts --maxWorkers=1 --minWorkers=1",
"test:e2e:mobile": "playwright test --project='Mobile Chrome'" "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": { "dependencies": {
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
+48 -6
View File
@@ -1,5 +1,8 @@
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { existsSync } from "node:fs"; 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 { runSupervisor } from "./supervisor.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
@@ -52,20 +55,59 @@ function resolveWorkerExecArgv(workerEntry: string): string[] {
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : []; return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
} }
const config = parseConfig(process.argv.slice(2)); async function main(): Promise<void> {
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry(); 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);
runSupervisor({ 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", name: "DaemonRunner",
startupMessage: config.devMode startupMessage: config.devMode
? "Starting daemon worker (dev mode, crash restarts enabled)" ? "Starting daemon worker (dev mode, crash restarts enabled)"
: "Starting daemon worker (IPC restart enabled)", : "Starting daemon worker (IPC restart enabled)",
resolveWorkerEntry: () => workerEntry, resolveWorkerEntry: () => workerEntry,
workerArgs: config.workerArgs, workerArgs: config.workerArgs,
workerEnv: process.env, workerEnv,
workerExecArgv: resolveWorkerExecArgv(workerEntry), workerExecArgv: resolveWorkerExecArgv(workerEntry),
restartOnCrash: config.devMode, 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 { fileURLToPath } from "url";
import { existsSync } from "node:fs";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { runSupervisor } from "./supervisor.js";
dotenv.config({ dotenv.config({
path: fileURLToPath(new URL("../.env", import.meta.url)), path: fileURLToPath(new URL("../.env", import.meta.url)),
quiet: true, quiet: true,
}); });
const WORKER_ENTRY = fileURLToPath(new URL("../src/server/index.ts", import.meta.url)); const daemonRunnerEntry = fileURLToPath(new URL("./daemon-runner.ts", import.meta.url));
if (!existsSync(WORKER_ENTRY)) { const result = spawnSync(
throw new Error(`Dev worker entry not found: ${WORKER_ENTRY}`); process.execPath,
[...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
{
stdio: "inherit",
env: process.env,
}
);
if (result.error) {
throw result.error;
} }
runSupervisor({ process.exit(result.status ?? 1);
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"],
});
@@ -5,7 +5,7 @@ import { z } from "zod";
const server = new McpServer({ name: "paseo-test-mcp", version: "1.0.0" }); const server = new McpServer({ name: "paseo-test-mcp", version: "1.0.0" });
server.tool( server.tool(
"echo", "paseo_roundtrip_text",
{ text: z.string() }, { text: z.string() },
async ({ text }) => ({ async ({ text }) => ({
content: [{ type: "text", text: `ECHO:${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)
})
})
+48 -18
View File
@@ -1,9 +1,13 @@
import { fork, type ChildProcess } from "child_process"; import { fork, type ChildProcess } from "child_process";
type RestartMessage = { type WorkerLifecycleMessage =
| {
type: "paseo:shutdown";
}
| {
type: "paseo:restart"; type: "paseo:restart";
reason?: string; reason?: string;
}; };
type SupervisorOptions = { type SupervisorOptions = {
name: string; name: string;
@@ -13,24 +17,32 @@ type SupervisorOptions = {
workerEnv?: NodeJS.ProcessEnv; workerEnv?: NodeJS.ProcessEnv;
workerExecArgv?: string[]; workerExecArgv?: string[];
restartOnCrash?: boolean; restartOnCrash?: boolean;
shutdownReasons?: string[]; onSupervisorExit?: () => Promise<void> | void;
}; };
function describeExit(code: number | null, signal: NodeJS.Signals | null): string { function describeExit(code: number | null, signal: NodeJS.Signals | null): string {
return signal ?? (typeof code === "number" ? `code ${code}` : "unknown"); return signal ?? (typeof code === "number" ? `code ${code}` : "unknown");
} }
function isRestartMessage(msg: unknown): msg is RestartMessage { function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
return ( if (typeof msg !== "object" || msg === null || !("type" in msg)) {
typeof msg === "object" && return null;
msg !== null && }
"type" in msg && const type = (msg as { type?: unknown }).type;
(msg as { type?: unknown }).type === "paseo:restart" 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 { export function runSupervisor(options: SupervisorOptions): void {
const shutdownReasons = new Set(options.shutdownReasons ?? ["cli_shutdown"]);
const restartOnCrash = options.restartOnCrash ?? false; const restartOnCrash = options.restartOnCrash ?? false;
const workerArgs = options.workerArgs ?? process.argv.slice(2); const workerArgs = options.workerArgs ?? process.argv.slice(2);
const workerEnv = options.workerEnv ?? process.env; const workerEnv = options.workerEnv ?? process.env;
@@ -39,11 +51,27 @@ export function runSupervisor(options: SupervisorOptions): void {
let child: ChildProcess | null = null; let child: ChildProcess | null = null;
let restarting = false; let restarting = false;
let shuttingDown = false; let shuttingDown = false;
let exiting = false;
const log = (message: string): void => { const log = (message: string): void => {
process.stderr.write(`[${options.name}] ${message}\n`); 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 = () => { const spawnWorker = () => {
let workerEntry: string; let workerEntry: string;
try { try {
@@ -52,7 +80,7 @@ export function runSupervisor(options: SupervisorOptions): void {
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
log(`Failed to resolve worker entry: ${message}`); log(`Failed to resolve worker entry: ${message}`);
process.exit(1); exitSupervisor(1);
return; return;
} }
@@ -63,12 +91,13 @@ export function runSupervisor(options: SupervisorOptions): void {
}); });
child.on("message", (msg: unknown) => { child.on("message", (msg: unknown) => {
if (!isRestartMessage(msg)) { const lifecycleMessage = parseLifecycleMessage(msg);
if (!lifecycleMessage) {
return; return;
} }
if (msg.reason && shutdownReasons.has(msg.reason)) { if (lifecycleMessage.type === "paseo:shutdown") {
requestShutdown(`Shutdown requested by worker (${msg.reason})`); requestShutdown("Shutdown requested by worker");
return; return;
} }
@@ -80,7 +109,8 @@ export function runSupervisor(options: SupervisorOptions): void {
if (shuttingDown) { if (shuttingDown) {
log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`);
process.exit(0); exitSupervisor(0);
return;
} }
if (restarting || (restartOnCrash && code !== 0 && code !== null)) { if (restarting || (restartOnCrash && code !== 0 && code !== null)) {
@@ -91,7 +121,7 @@ export function runSupervisor(options: SupervisorOptions): void {
} }
log(`Worker exited (${exitDescriptor}). Supervisor exiting.`); 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; restarting = false;
log(`${reason}. Stopping worker...`); log(`${reason}. Stopping worker...`);
if (!child) { if (!child) {
process.exit(0); exitSupervisor(0);
return; return;
} }
child.kill("SIGTERM"); child.kill("SIGTERM");
@@ -206,6 +206,119 @@ describe('DaemonClient', () => {
expect(client.getConnectionState().status).toBe('disposed') 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 () => { test('transitions out of connecting when connect timeout elapses', async () => {
vi.useFakeTimers() vi.useFakeTimers()
try { try {
+31 -1
View File
@@ -5,6 +5,7 @@ import {
AgentRefreshedStatusPayloadSchema, AgentRefreshedStatusPayloadSchema,
AgentResumedStatusPayloadSchema, AgentResumedStatusPayloadSchema,
RestartRequestedStatusPayloadSchema, RestartRequestedStatusPayloadSchema,
ShutdownRequestedStatusPayloadSchema,
SessionInboundMessageSchema, SessionInboundMessageSchema,
type WSWelcomeMessage, type WSWelcomeMessage,
WSOutboundMessageSchema, WSOutboundMessageSchema,
@@ -275,6 +276,7 @@ export type FetchAgentTimelineOptions = {
type AgentRefreshedStatusPayload = z.infer<typeof AgentRefreshedStatusPayloadSchema> type AgentRefreshedStatusPayload = z.infer<typeof AgentRefreshedStatusPayloadSchema>
type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema> type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema>
type ShutdownRequestedStatusPayload = z.infer<typeof ShutdownRequestedStatusPayloadSchema>
type FetchAgentsPayload = Extract< type FetchAgentsPayload = Extract<
SessionOutboundMessage, SessionOutboundMessage,
{ type: 'fetch_agents_response' } { 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 // Audio / Voice
// ============================================================================ // ============================================================================
@@ -2203,7 +2232,8 @@ export class DaemonClient {
cwd: options?.cwd, cwd: options?.cwd,
}, },
responseType: 'list_provider_models_response', 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({ await waitForPathExists({
targetPath: path.join(worktreePath, "dev-terminal.txt"), targetPath: path.join(worktreePath, "dev-terminal.txt"),
timeoutMs: 15000, timeoutMs: 30000,
}); });
} finally { } finally {
if (agentId) { if (agentId) {
@@ -93,7 +93,7 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [ export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
{ provider: "claude", model: "haiku" }, { 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" }, { provider: "opencode", model: "opencode/kimi-k2.5-free" },
] as const; ] as const;
@@ -8,57 +8,83 @@
* the Claude Agent SDK's command capabilities. * 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 { describe, it, expect, beforeAll, afterAll } from "vitest";
import { ClaudeAgentClient } from "./claude-agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
import type { AgentSession, AgentSessionConfig, AgentSlashCommand } from "../agent-sdk-types.js"; import type { AgentSession, AgentSessionConfig, AgentSlashCommand } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { useTempClaudeConfigDir } from "../../test-utils/claude-config.js";
const hasClaudeCredentials = const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
(hasClaudeCredentials ? describe : describe.skip)("ClaudeAgentSession Commands", () => { (hasClaudeCredentials ? describe : describe.skip)("ClaudeAgentSession Commands", () => {
let client: ClaudeAgentClient; 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 buildTestConfig = (cwd: string): AgentSessionConfig => ({
const testConfig: AgentSessionConfig = {
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd,
modeId: "plan", modeId: "plan",
}; });
beforeAll(async () => { 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() }); 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 () => { afterAll(async () => {
try {
if (session) { if (session) {
await session.close(); await session.close();
} }
} finally {
session = null;
if (tempCwd) {
rmSync(tempCwd, { recursive: true, force: true });
tempCwd = null;
}
restoreClaudeConfigDir?.();
restoreClaudeConfigDir = null;
}
}); });
describe("listCommands()", () => { describe("listCommands()", () => {
it("should return an array of AgentSlashCommand objects", async () => { 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 // The session should have a listCommands method
expect(typeof session.listCommands).toBe("function"); expect(typeof session.listCommands).toBe("function");
const commands = await session.listCommands!();
// Should be an array // Should be an array
expect(Array.isArray(commands)).toBe(true); expect(Array.isArray(commands)).toBe(true);
// Should have at least some built-in commands // Should have at least some built-in commands
expect(commands.length).toBeGreaterThan(0); expect(commands.length).toBeGreaterThan(0);
await session.close();
}, 30000); }, 30000);
it("should have valid AgentSlashCommand structure for all commands", async () => { it("should have valid AgentSlashCommand structure for all commands", async () => {
session = await client.createSession(testConfig); if (!session) {
throw new Error("Claude test session not initialized");
const commands = await session.listCommands!(); }
// Verify all commands have valid structure // Verify all commands have valid structure
for (const cmd of commands) { for (const cmd of commands) {
@@ -72,22 +98,19 @@ const hasClaudeCredentials =
// Names should NOT have the / prefix (that's added when executing) // Names should NOT have the / prefix (that's added when executing)
expect(cmd.name.startsWith("/")).toBe(false); expect(cmd.name.startsWith("/")).toBe(false);
} }
await session.close();
}, 30000); }, 30000);
it("should include user-defined skills", async () => { 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); const commandNames = commands.map((cmd) => cmd.name);
// Should have at least one command (skills are loaded from user/project settings) // Should have at least one command (skills are loaded from user/project settings)
// The exact commands depend on what skills are configured // The exact commands depend on what skills are configured
expect(commands.length).toBeGreaterThan(0); expect(commands.length).toBeGreaterThan(0);
expect(commandNames).toContain("rewind"); expect(commandNames).toContain("rewind");
await session.close();
}, 30000); }, 30000);
}); });
@@ -23,6 +23,7 @@ import type { AgentStreamEventPayload } from "../../messages.js";
import type { import type {
AgentProvider, AgentProvider,
AgentPermissionRequest, AgentPermissionRequest,
AgentSession,
AgentSessionConfig, AgentSessionConfig,
AgentStreamEvent, AgentStreamEvent,
AgentTimelineItem, 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) { async function autoApprove(session: Awaited<ReturnType<ClaudeAgentClient["createSession"]>>, event: AgentStreamEvent) {
if (event.type === "permission_requested") { if (event.type === "permission_requested") {
await session.respondToPermission(event.request.id, { behavior: "allow" }); await session.respondToPermission(event.request.id, { behavior: "allow" });
@@ -288,15 +297,16 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 1024 }); const config = buildConfig(cwd, { maxThinkingTokens: 1024 });
const session = await client.createSession(config); const session = await client.createSession(config);
try {
const marker = "CLAUDE_ACK_TOKEN"; const marker = "CLAUDE_ACK_TOKEN";
const result = await session.run( const result = await session.run(
`Reply with the exact text ${marker} and then stop.` `Reply with the exact text ${marker} and then stop.`
); );
expect(result.finalText).toContain(marker); expect(result.finalText).toContain(marker);
} finally {
await session.close(); await closeSessionAndCleanup(session, cwd);
rmSync(cwd, { recursive: true, force: true }); }
}, },
120_000 120_000
); );
@@ -309,6 +319,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 2048 }); const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config); const session = await client.createSession(config);
try {
const events = session.stream( const events = session.stream(
"Think step by step about the pros and cons of single-file tests, but only share a short plan." "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); expect(sawReasoning).toBe(true);
} finally {
await session.close(); await closeSessionAndCleanup(session, cwd);
rmSync(cwd, { recursive: true, force: true }); }
}, },
120_000 120_000
); );
@@ -421,6 +432,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 1024 }); const config = buildConfig(cwd, { maxThinkingTokens: 1024 });
const session = await client.createSession(config); const session = await client.createSession(config);
try {
const events = session.stream( 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." "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"); const filePath = path.join(cwd, "tool-test.txt");
expect(existsSync(filePath)).toBe(true); expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf8")).toContain("hello world"); expect(readFileSync(filePath, "utf8")).toContain("hello world");
} finally {
await session.close(); await closeSessionAndCleanup(session, cwd);
rmSync(cwd, { recursive: true, force: true }); }
}, },
180_000 180_000
); );
@@ -775,6 +787,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 2048 }); const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config); const session = await client.createSession(config);
try {
const first = await session.run("Respond only with the word alpha."); const first = await session.run("Respond only with the word alpha.");
expect(first.finalText.toLowerCase()).toContain("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." "Without adding any explanations, repeat exactly the same word you just said."
); );
expect(second.finalText.toLowerCase()).toContain("alpha"); expect(second.finalText.toLowerCase()).toContain("alpha");
} finally {
await session.close(); await closeSessionAndCleanup(session, cwd);
rmSync(cwd, { recursive: true, force: true }); }
}, },
120_000 120_000
); );
@@ -853,7 +866,9 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const client = new ClaudeAgentClient({ logger }); const client = new ClaudeAgentClient({ logger });
const config = buildConfig(cwd, { maxThinkingTokens: 1024 }); const config = buildConfig(cwd, { maxThinkingTokens: 1024 });
const session = await client.createSession(config); 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 // Store a specific word in a file to create history and enable recall
const timestamp = Date.now(); const timestamp = Date.now();
const secretWord = `XYZZY${timestamp}PLUGH`; const secretWord = `XYZZY${timestamp}PLUGH`;
@@ -885,7 +900,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
expect(await waitForHistoryContains(historyPaths, secretWord)).toBe(true); expect(await waitForHistoryContains(historyPaths, secretWord)).toBe(true);
// Resume and verify context is preserved // 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 // Verify history is emitted on resume
const historyEvents: AgentStreamEvent[] = []; const historyEvents: AgentStreamEvent[] = [];
@@ -921,9 +936,10 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
resumedResult.finalText.includes("XYZZY") || resumedResult.finalText.includes("XYZZY") ||
resumedResult.finalText.includes("PLUGH"); resumedResult.finalText.includes("PLUGH");
expect(recalledSomething).toBe(true); expect(recalledSomething).toBe(true);
} finally {
await resumed.close(); await resumed?.close();
rmSync(cwd, { recursive: true, force: true }); await closeSessionAndCleanup(session, cwd);
}
}, },
180_000 180_000
); );
@@ -936,6 +952,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 1024 }); const config = buildConfig(cwd, { maxThinkingTokens: 1024 });
const session = await client.createSession(config); const session = await client.createSession(config);
try {
const modes = await session.getAvailableModes(); const modes = await session.getAvailableModes();
expect(modes.map((m) => m.id)).toContain("plan"); 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." "Just reply with the word PLAN to confirm you're still responsive."
); );
expect(result.finalText.toLowerCase()).toContain("plan"); expect(result.finalText.toLowerCase()).toContain("plan");
} finally {
await session.close(); await closeSessionAndCleanup(session, cwd);
rmSync(cwd, { recursive: true, force: true }); }
}, },
120_000 120_000
); );
@@ -960,6 +977,8 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const client = new ClaudeAgentClient({ logger }); const client = new ClaudeAgentClient({ logger });
const config = buildConfig(cwd, { maxThinkingTokens: 2048 }); const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config); const session = await client.createSession(config);
try {
await session.setMode("plan"); await session.setMode("plan");
const events = session.stream( const events = session.stream(
@@ -993,9 +1012,9 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const filePath = path.join(cwd, "dummy.txt"); const filePath = path.join(cwd, "dummy.txt");
expect(existsSync(filePath)).toBe(true); expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf8")).toContain("plan-test"); expect(readFileSync(filePath, "utf8")).toContain("plan-test");
} finally {
await session.close(); await closeSessionAndCleanup(session, cwd);
rmSync(cwd, { recursive: true, force: true }); }
}, },
180_000 180_000
); );
@@ -1008,6 +1027,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 2048 }); const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config); const session = await client.createSession(config);
try {
const prompt = [ const prompt = [
"You must call the AskUserQuestion tool exactly once and wait for the user's answer.", "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.", "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(sawResolvedAllow).toBe(true);
expect(session.getPendingPermissions()).toHaveLength(0); expect(session.getPendingPermissions()).toHaveLength(0);
expect(sawDone).toBe(true); expect(sawDone).toBe(true);
} finally {
await session.close(); await closeSessionAndCleanup(session, cwd);
rmSync(cwd, { recursive: true, force: true }); }
}, },
180_000 180_000
); );
@@ -1271,6 +1291,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const config = buildConfig(cwd, { maxThinkingTokens: 2048 }); const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config); const session = await client.createSession(config);
try {
const events = session.stream( const events = session.stream(
"Use the Task tool to launch a sub-agent that reads the current directory listing. " + "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. " + "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]")); const taskLines = lines.filter((l) => l.includes("[Task]"));
// Each Task callId should appear at most once in curated output // Each Task callId should appear at most once in curated output
expect(taskLines.length).toBeLessThanOrEqual(taskCalls.length); expect(taskLines.length).toBeLessThanOrEqual(taskCalls.length);
} finally {
await session.close(); await closeSessionAndCleanup(session, cwd);
rmSync(cwd, { recursive: true, force: true }); }
}, },
180_000 180_000
); );
@@ -74,6 +74,34 @@ const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = [
"user", "user",
"project", "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"; type TurnState = "idle" | "foreground" | "autonomous";
@@ -129,6 +157,66 @@ type ForegroundTurnState = {
type ClaudeModelFamily = "sonnet" | "opus" | "haiku"; 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 { function normalizeClaudeModelLabel(model: ModelInfo): string {
const fallback = model.displayName?.trim() || model.value; const fallback = model.displayName?.trim() || model.value;
const prefix = model.description?.split(/[·•]/)[0]?.trim() || ""; const prefix = model.description?.split(/[·•]/)[0]?.trim() || "";
@@ -190,15 +278,6 @@ function inferClaudeModelFamilyFromText(
return null; 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( function pickFamilyAliasModelId(
familyAliases: ReadonlyMap<ClaudeModelFamily, string> | null | undefined, familyAliases: ReadonlyMap<ClaudeModelFamily, string> | null | undefined,
family: ClaudeModelFamily family: ClaudeModelFamily
@@ -1468,25 +1547,38 @@ export class ClaudeAgentClient implements AgentClient {
options: this.applyRuntimeSettings(claudeOptions), options: this.applyRuntimeSettings(claudeOptions),
}); });
try { try {
const models: ModelInfo[] = await claudeQuery.supportedModels(); const models: ModelInfo[] = await withTimeout({
return models.map((model) => ({ promise: claudeQuery.supportedModels(),
provider: "claude" as const, 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, id: model.value,
label: normalizeClaudeModelLabel(model), label: normalizeClaudeModelLabel(model),
description: model.description, description: model.description,
thinkingOptions: [ })
{ id: "off", label: "Off", isDefault: true }, );
{ id: "on", label: "On" }, } catch (error) {
], this.logger.warn(
defaultThinkingOptionId: "off", { err: error },
metadata: { "Failed to fetch Claude model catalog from SDK; using fallback Claude model aliases"
description: model.description, );
}, return buildClaudeFallbackModels();
}));
} finally { } finally {
if (typeof claudeQuery.return === "function") { if (typeof claudeQuery.return === "function") {
try { try {
await claudeQuery.return(); await withTimeout({
promise: claudeQuery.return(),
timeoutMs: CLAUDE_MODEL_QUERY_SHUTDOWN_TIMEOUT_MS,
label: "Claude model query shutdown",
});
} catch { } catch {
// ignore shutdown errors // ignore shutdown errors
} }
@@ -2262,38 +2354,6 @@ class ClaudeAgentSession implements AgentSession {
this.userMessageIds.push(messageId); 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> { private async ensureQuery(): Promise<Query> {
if (this.query && !this.queryRestartNeeded) { if (this.query && !this.queryRestartNeeded) {
return this.query; return this.query;
@@ -2315,12 +2375,10 @@ class ClaudeAgentSession implements AgentSession {
); );
this.input = input; this.input = input;
this.query = query({ prompt: input, options }); this.query = query({ prompt: input, options });
// Do not block query readiness on control-plane calls. We need `next()` to // Do not kick off background control-plane queries here. Methods like
// start immediately so autonomous wake events are not missed between turns. // supportedCommands()/setPermissionMode() may execute immediately after
void this.awaitWithTimeout( // ensureQuery() (for listCommands()/setMode()), and sharing the same query
this.primeSelectableModelIds(this.query), // control plane can cause those calls to wait behind supportedModels().
"prime selectable model ids"
);
return this.query; 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 () => { test.runIf(isCodexInstalled())("listModels returns live Codex models", async () => {
const client = new CodexAppServerAgentClient(logger); const client = new CodexAppServerAgentClient(logger);
const models = await client.listModels(); const models = await client.listModels();
@@ -457,6 +480,15 @@ describe("Codex app-server provider (integration)", () => {
modeId: "read-only", modeId: "read-only",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
extra: {
codex: {
tools: {
shell: false,
list_mcp_resources: false,
list_mcp_resource_templates: false,
},
},
},
mcpServers: { mcpServers: {
paseo_test: { paseo_test: {
type: "stdio", type: "stdio",
@@ -468,7 +500,7 @@ describe("Codex app-server provider (integration)", () => {
const result = await session.run( 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}`, `Call it with text: ${token}`,
"Do not use shell or any non-MCP tools.", "Do not use shell or any non-MCP tools.",
"After the tool call, respond with exactly the tool output text.", "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" item.type === "tool_call"
); );
const toolNames = toolCalls.map((item) => item.name); 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" }>>(); const distinctMcpCalls = new Map<string, Extract<AgentTimelineItem, { type: "tool_call" }>>();
for (const call of toolCalls) { for (const call of toolCalls) {
if (call.name !== "paseo_test.echo") { if (call.name !== "paseo_test.paseo_roundtrip_text") {
continue; continue;
} }
const key = String(call.callId ?? `${call.name}:${JSON.stringify(call.detail)}`); 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. // 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); expect(distinctMcpCalls.size).toBe(1);
const mcpToolCall = Array.from(distinctMcpCalls.values())[0]!; 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"); expect(mcpToolCall.status).toBe("completed");
// Hard assertion: no non-MCP tools in this run. // 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); expect(toolNames.some((name) => name.toLowerCase().includes("shell"))).toBe(false);
// Hard assertion: roundtrip token must be present in the MCP tool I/O. // 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 cleanup = useTempCodexSessionDir();
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"); const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
const promptsDir = path.join(codexHome, "prompts"); 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 cwd = tmpCwd("codex-cmd-");
const token = `PASEO_PROMPT_TOKEN_${Date.now()}`; const token = `PASEO_PROMPT_TOKEN_${Date.now()}`;
@@ -549,13 +594,37 @@ describe("Codex app-server provider (integration)", () => {
}); });
try { try {
const commands = await session.listCommands?.(); 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 executeArgs = "NAME=world extra_value";
const expectedExpanded = `${token}::name=world::pos1=extra_value::dollar=$`; 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); const runResult = await session.run(rawSlashInput);
expect(runResult.finalText).toContain(expectedExpanded); 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 { } finally {
await session.close(); await session.close();
} }
@@ -574,7 +643,8 @@ describe("Codex app-server provider (integration)", () => {
const cleanup = useTempCodexSessionDir(); const cleanup = useTempCodexSessionDir();
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"); const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
const promptsDir = path.join(codexHome, "prompts"); 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 cwd = tmpCwd("codex-cmd-stream-");
const token = `PASEO_STREAM_TOKEN_${Date.now()}`; const token = `PASEO_STREAM_TOKEN_${Date.now()}`;
@@ -600,7 +670,7 @@ describe("Codex app-server provider (integration)", () => {
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
}); });
try { try {
const events = session.stream("/prompts:stream-test"); const events = session.stream(`/prompts:${promptName}`);
const seenTypes = new Set<string>(); const seenTypes = new Set<string>();
const assistantChunks: string[] = []; const assistantChunks: string[] = [];
for await (const event of events) { for await (const event of events) {
@@ -774,12 +844,17 @@ describe("Codex app-server provider (integration)", () => {
expect(sawPermission).toBe(true); expect(sawPermission).toBe(true);
expect(sawPermissionResolvedDeny).toBe(true); expect(sawPermissionResolvedDeny).toBe(true);
expect( expect(captured).not.toBeNull();
timelineItems.some( const deniedShellCall = timelineItems.find(
(item) => (item) =>
item.status === "failed" && hasShellCommand(item, "permission-deny.txt") item.name === "shell" &&
) item.status === "failed" &&
).toBe(true); 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); expect(existsSync(filePath)).toBe(true);
} finally { } finally {
cleanup(); cleanup();
@@ -796,16 +871,6 @@ describe("Codex app-server provider (integration)", () => {
const patchFile = path.join(cwd, "patch.txt"); const patchFile = path.join(cwd, "patch.txt");
try { 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 sawAssistantMessage = false;
let sawShellTool = false; let sawShellTool = false;
let sawPatchTool = false; let sawPatchTool = false;
@@ -813,13 +878,23 @@ describe("Codex app-server provider (integration)", () => {
let sawPatchCompleted = false; let sawPatchCompleted = false;
const timelineItems: AgentTimelineItem[] = []; 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." "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) { for await (const event of shellEvents) {
if (event.type === "permission_requested") { 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") { if (event.type === "timeline") {
timelineItems.push(event.item); timelineItems.push(event.item);
@@ -841,6 +916,9 @@ describe("Codex app-server provider (integration)", () => {
break; break;
} }
} }
} finally {
await shellSession.close();
}
if (failure) { if (failure) {
throw new Error(failure); throw new Error(failure);
@@ -852,7 +930,17 @@ describe("Codex app-server provider (integration)", () => {
"+patched", "+patched",
"*** End Patch", "*** End Patch",
].join("\n"); ].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", { buildStrictApplyPatchPrompt(patch, "PATCH_DONE", {
includePermissionStep: true, includePermissionStep: true,
}) })
@@ -860,7 +948,7 @@ describe("Codex app-server provider (integration)", () => {
for await (const event of patchEvents) { for await (const event of patchEvents) {
if (event.type === "permission_requested") { 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") { if (event.type === "timeline") {
timelineItems.push(event.item); timelineItems.push(event.item);
@@ -882,8 +970,9 @@ describe("Codex app-server provider (integration)", () => {
break; break;
} }
} }
} finally {
await session.close(); await patchSession.close();
}
if (failure) { if (failure) {
throw new Error(failure); throw new Error(failure);
@@ -1294,11 +1383,16 @@ describe("Codex app-server provider (integration)", () => {
return result.value; 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; const hardDeadline = Date.now() + 45_000;
while (Date.now() < hardDeadline) { while (Date.now() < hardDeadline) {
const event = await nextEvent(10_000); const remainingMs = hardDeadline - Date.now();
if (!event) break; const pollWindowMs = Math.max(250, Math.min(10_000, remainingMs));
const event = await nextEvent(pollWindowMs);
if (!event) {
continue;
}
if (event.type === "permission_requested") { if (event.type === "permission_requested") {
await session.respondToPermission(event.request.id, { behavior: "allow" }); await session.respondToPermission(event.request.id, { behavior: "allow" });
@@ -812,6 +812,20 @@ function normalizeCodexCommandValue(
} }
function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] { 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") { if (!changes || typeof changes !== "object") {
return []; return [];
} }
@@ -823,10 +837,7 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
return null; return null;
} }
const record = entry as Record<string, unknown>; const record = entry as Record<string, unknown>;
const pathValue = const pathValue = resolvePathFromRecord(record);
typeof record.path === "string" && record.path.trim().length > 0
? record.path.trim()
: "";
if (!pathValue) { if (!pathValue) {
return null; return null;
} }
@@ -843,10 +854,11 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
} }
const recordChanges = changes as Record<string, unknown>; const recordChanges = changes as Record<string, unknown>;
if (typeof recordChanges.path === "string" && recordChanges.path.trim().length > 0) { const directPathValue = resolvePathFromRecord(recordChanges);
if (directPathValue) {
return [ return [
{ {
path: recordChanges.path.trim(), path: directPathValue,
kind: kind:
(typeof recordChanges.kind === "string" && recordChanges.kind) || (typeof recordChanges.kind === "string" && recordChanges.kind) ||
(typeof recordChanges.type === "string" && recordChanges.type) || (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); await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
let sawTurnStarted = false;
for await (const event of queue) { 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; yield event;
if ( if (
event.type === "turn_completed" || 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", () => { it("maps write/edit/search known variants with distinct detail types", () => {
const writeItem = mapCodexToolCallFromThreadItem( const writeItem = mapCodexToolCallFromThreadItem(
{ {
@@ -210,19 +210,7 @@ const CodexFileChangeItemSchema = z
id: z.string().min(1), id: z.string().min(1),
status: z.string().optional(), status: z.string().optional(),
error: z.unknown().optional(), error: z.unknown().optional(),
changes: z changes: z.unknown().optional(),
.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(),
}) })
.passthrough(); .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( function mapFileChangeItem(
item: z.infer<typeof CodexFileChangeItemSchema>, item: z.infer<typeof CodexFileChangeItemSchema>,
options?: CodexMapperOptions options?: CodexMapperOptions
): CodexNormalizedToolCallEnvelope { ): CodexNormalizedToolCallEnvelope {
const changes = item.changes ?? []; const files = parseFileChangeEntries(item.changes, options);
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 inputBase = { const inputBase = {
...(files.length > 0 ...(files.length > 0
+44 -4
View File
@@ -99,6 +99,19 @@ export type PaseoSpeechConfig = {
local?: PaseoLocalSpeechConfig; local?: PaseoLocalSpeechConfig;
}; };
export type DaemonLifecycleIntent =
| {
type: "shutdown";
clientId: string;
requestId: string;
}
| {
type: "restart";
clientId: string;
requestId: string;
reason?: string;
};
export type PaseoDaemonConfig = { export type PaseoDaemonConfig = {
listen: string; listen: string;
paseoHome: string; paseoHome: string;
@@ -121,6 +134,11 @@ export type PaseoDaemonConfig = {
dictationFinalTimeoutMs?: number; dictationFinalTimeoutMs?: number;
downloadTokenTtlMs?: number; downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap; agentProviderSettings?: AgentProviderRuntimeSettingsMap;
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
pidLock?: {
mode?: "self" | "external";
ownerPid?: number;
};
}; };
export interface PaseoDaemon { export interface PaseoDaemon {
@@ -138,9 +156,16 @@ export async function createPaseoDaemon(
): Promise<PaseoDaemon> { ): Promise<PaseoDaemon> {
const logger = rootLogger.child({ module: "bootstrap" }); const logger = rootLogger.child({ module: "bootstrap" });
const daemonVersion = resolveDaemonVersion(import.meta.url); 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. // 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 { try {
const serverId = getOrCreateServerId(config.paseoHome, { logger }); const serverId = getOrCreateServerId(config.paseoHome, { logger });
@@ -499,7 +524,14 @@ export async function createPaseoDaemon(
getSpeechReadiness, getSpeechReadiness,
}, },
config.agentProviderSettings, 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) => { unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
wsServer?.publishSpeechReadiness(snapshot); wsServer?.publishSpeechReadiness(snapshot);
@@ -601,7 +633,11 @@ export async function createPaseoDaemon(
unlinkSync(listenTarget.path); unlinkSync(listenTarget.path);
} }
// Release PID lock // Release PID lock
await releasePidLock(config.paseoHome); if (ownsPidLock) {
await releasePidLock(config.paseoHome, {
ownerPid: pidLockOwnerPid,
});
}
}; };
return { return {
@@ -613,7 +649,11 @@ export async function createPaseoDaemon(
stop, stop,
}; };
} catch (err) { } catch (err) {
await releasePidLock(config.paseoHome).catch(() => undefined); if (ownsPidLock) {
await releasePidLock(config.paseoHome, {
ownerPid: pidLockOwnerPid,
}).catch(() => undefined);
}
throw err; throw err;
} }
} }
@@ -11,6 +11,7 @@ import {
} from "./test-utils/paseo-daemon.js"; } from "./test-utils/paseo-daemon.js";
import { DaemonClient } from "./test-utils/daemon-client.js"; import { DaemonClient } from "./test-utils/daemon-client.js";
import type { AgentStreamEventPayload } from "../shared/messages.js"; import type { AgentStreamEventPayload } from "../shared/messages.js";
import type { AgentSnapshotPayload } from "./messages.js";
/** /**
* Tests for client activity tracking and smart notifications. * 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 * - appVisible: whether the app/tab is in foreground
*/ */
describe("client activity tracking", () => { describe("client activity tracking", () => {
const TEST_PROVIDER = "claude";
const TEST_MODEL = "claude-haiku-4-5";
const TEST_CWD = "/tmp";
let daemon: TestPaseoDaemon; let daemon: TestPaseoDaemon;
let client1: DaemonClient; let client1: DaemonClient;
let client2: DaemonClient; let client2: DaemonClient;
@@ -55,6 +59,19 @@ describe("client activity tracking", () => {
return client; 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( function waitForAttentionRequired(
client: DaemonClient, client: DaemonClient,
agentId: string, agentId: string,
@@ -86,10 +103,8 @@ describe("client activity tracking", () => {
test("no notification when actively focused on agent", async () => { test("no notification when actively focused on agent", async () => {
client1 = await createClient(); client1 = await createClient();
const agent = await client1.createAgent({ const agent = await createUiAgent({
provider: "claude", client: client1,
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Active Focus Test", title: "Active Focus Test",
}); });
@@ -114,19 +129,9 @@ describe("client activity tracking", () => {
test("notification when focused on different agent", async () => { test("notification when focused on different agent", async () => {
client1 = await createClient(); client1 = await createClient();
const agent1 = await client1.createAgent({ const agent1 = await createUiAgent({ client: client1, title: "Agent 1" });
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Agent 1",
});
const agent2 = await client1.createAgent({ const agent2 = await createUiAgent({ client: client1, title: "Agent 2" });
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Agent 2",
});
// User is looking at agent2, not agent1 // User is looking at agent2, not agent1
client1.sendHeartbeat({ 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 () => { test("no notification when app is not visible but activity is recent (user just switched tabs)", async () => {
client1 = await createClient(); client1 = await createClient();
const agent = await client1.createAgent({ const agent = await createUiAgent({
provider: "claude", client: client1,
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "App Hidden Test", 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 () => { test("notification when activity is stale (user walked away for 2+ minutes)", async () => {
client1 = await createClient(); client1 = await createClient();
const agent = await client1.createAgent({ const agent = await createUiAgent({
provider: "claude", client: client1,
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Stale Activity Test", title: "Stale Activity Test",
}); });
@@ -209,10 +210,8 @@ describe("client activity tracking", () => {
test("notification when no heartbeat received (legacy/new client)", async () => { test("notification when no heartbeat received (legacy/new client)", async () => {
client1 = await createClient(); client1 = await createClient();
const agent = await client1.createAgent({ const agent = await createUiAgent({
provider: "claude", client: client1,
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "No Heartbeat Test", title: "No Heartbeat Test",
}); });
@@ -237,10 +236,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); client1 = await createClient();
client2 = await createClient(); client2 = await createClient();
const agent = await client1.createAgent({ const agent = await createUiAgent({
provider: "claude", client: client1,
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Two Tabs Test", title: "Two Tabs Test",
}); });
@@ -280,10 +277,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); client1 = await createClient();
client2 = await createClient(); client2 = await createClient();
const agent = await client1.createAgent({ const agent = await createUiAgent({
provider: "claude", client: client1,
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Both Inactive Test", title: "Both Inactive Test",
}); });
@@ -330,10 +325,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile client2 = await createClient(); // mobile
const agent = await client1.createAgent({ const agent = await createUiAgent({
provider: "claude", client: client1,
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Web Active Test", title: "Web Active Test",
}); });
@@ -373,10 +366,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile client2 = await createClient(); // mobile
const agent = await client1.createAgent({ const agent = await createUiAgent({
provider: "claude", client: client1,
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Mobile Active Test", title: "Mobile Active Test",
}); });
@@ -416,10 +407,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile client2 = await createClient(); // mobile
const agent = await client1.createAgent({ const agent = await createUiAgent({
provider: "claude", client: client1,
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Web Stale Test", title: "Web Stale Test",
}); });
@@ -459,19 +448,9 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile client2 = await createClient(); // mobile
const agent1 = await client1.createAgent({ const agent1 = await createUiAgent({ client: client1, title: "Agent 1" });
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Agent 1",
});
const agent2 = await client1.createAgent({ const agent2 = await createUiAgent({ client: client1, title: "Agent 2" });
provider: "claude",
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Agent 2",
});
// Web: active but looking at agent2 // Web: active but looking at agent2
client1.sendHeartbeat({ client1.sendHeartbeat({
@@ -510,10 +489,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile client2 = await createClient(); // mobile
const agent = await client1.createAgent({ const agent = await createUiAgent({
provider: "claude", client: client1,
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Both Inactive Test", title: "Both Inactive Test",
}); });
@@ -562,10 +539,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile - no heartbeat client2 = await createClient(); // mobile - no heartbeat
const agent = await client1.createAgent({ const agent = await createUiAgent({
provider: "claude", client: client1,
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Mobile No Heartbeat Test", 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 () => { test("no notification when app not visible but activity recent (switched tabs recently)", async () => {
client1 = await createClient(); client1 = await createClient();
const agent = await client1.createAgent({ const agent = await createUiAgent({
provider: "claude", client: client1,
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Tab Switch Test", title: "Tab Switch Test",
}); });
@@ -630,10 +603,8 @@ describe("client activity tracking", () => {
client1 = await createClient(); // web client1 = await createClient(); // web
client2 = await createClient(); // mobile client2 = await createClient(); // mobile
const agent = await client1.createAgent({ const agent = await createUiAgent({
provider: "claude", client: client1,
model: "claude-haiku-4-5",
cwd: "/tmp",
title: "Both Recent Activity Test", title: "Both Recent Activity Test",
}); });
@@ -244,7 +244,9 @@ describe("daemon checkout ship loop", () => {
expect(existsSync(worktree.worktreePath)).toBe(false); expect(existsSync(worktree.worktreePath)).toBe(false);
const remainingAgents = await ctx.client.fetchAgents(); 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 { } finally {
if (agentId) { if (agentId) {
await ctx.client.deleteAgent(agentId).catch(() => undefined); await ctx.client.deleteAgent(agentId).catch(() => undefined);
@@ -1,27 +1,6 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { describe, test, expect } 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-"));
}
describe("daemon E2E", () => { describe("daemon E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("multi-agent orchestration", () => { describe("multi-agent orchestration", () => {
// TODO: Re-implement orchestration tests with new Paseo MCP // TODO: Re-implement orchestration tests with new Paseo MCP
// The old agent-control MCP has been removed // 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); await waitForCondition(() => sawExit, 10000);
@@ -457,6 +458,60 @@ const shouldRun = !process.env.CI;
ws.once("error", reject); 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 attachRequestId = `attach-${Date.now()}`;
const detachRequestId = `detach-${Date.now()}`; const detachRequestId = `detach-${Date.now()}`;
let streamId: number | null = null; let streamId: number | null = null;
@@ -546,7 +601,7 @@ const shouldRun = !process.env.CI;
terminalId, terminalId,
message: { message: {
type: "input", 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 { createRootLogger } from "./logger.js";
import { loadPersistedConfig } from "./persisted-config.js"; import { loadPersistedConfig } from "./persisted-config.js";
import { PidLockError } from "./pid-lock.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() { async function main() {
let paseoHome: string; let paseoHome: string;
let logger: ReturnType<typeof createRootLogger>; let logger: ReturnType<typeof createRootLogger>;
let config: ReturnType<typeof loadConfig>; let config: ReturnType<typeof loadConfig>;
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
let shutdownPromise: Promise<number> | null = null;
let exitHookInstalled = false;
try { try {
paseoHome = resolvePaseoHome(); paseoHome = resolvePaseoHome();
@@ -28,9 +41,107 @@ async function main() {
config.mcpEnabled = false; 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 { 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) { } catch (err) {
if (err instanceof PidLockError) { if (err instanceof PidLockError) {
logger.error({ pid: err.existingLock?.pid }, err.message); logger.error({ pid: err.existingLock?.pid }, err.message);
@@ -49,34 +160,8 @@ async function main() {
throw err; throw err;
} }
let shuttingDown = false; process.on("SIGTERM", () => beginShutdown("SIGTERM"));
const handleShutdown = async (signal: string) => { process.on("SIGINT", () => beginShutdown("SIGINT"));
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"));
} }
main().catch((err) => { 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"); return join(paseoHome, "paseo.pid");
} }
function resolveLockOwnerPid(): number { function resolveOwnerPid(ownerPid?: number): number {
if (typeof process.send === "function") { if (typeof ownerPid === "number" && Number.isInteger(ownerPid) && ownerPid > 0) {
const ppid = process.ppid; return ownerPid;
if (Number.isInteger(ppid) && ppid > 1) {
return ppid;
} }
}
return process.pid; return process.pid;
} }
export async function acquirePidLock( export async function acquirePidLock(
paseoHome: string, paseoHome: string,
sockPath: string sockPath: string,
options?: { ownerPid?: number }
): Promise<void> { ): Promise<void> {
const pidPath = getPidFilePath(paseoHome); const pidPath = getPidFilePath(paseoHome);
@@ -66,7 +63,7 @@ export async function acquirePidLock(
} }
// Check if existing lock is stale // Check if existing lock is stale
const lockOwnerPid = resolveLockOwnerPid(); const lockOwnerPid = resolveOwnerPid(options?.ownerPid);
if (existingLock) { if (existingLock) {
if (isPidRunning(existingLock.pid)) { if (isPidRunning(existingLock.pid)) {
if (existingLock.pid === lockOwnerPid) { 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 pidPath = getPidFilePath(paseoHome);
const lockOwnerPid = resolveLockOwnerPid(); const lockOwnerPid = resolveOwnerPid(options?.ownerPid);
try { try {
// Only remove if it's our lock // Only remove if it's our lock
const content = await readFile(pidPath, "utf-8"); const content = await readFile(pidPath, "utf-8");
@@ -324,6 +324,7 @@ export function startRelayTransport({
attached = true; attached = true;
const externalMetadata: ExternalSocketMetadata = { const externalMetadata: ExternalSocketMetadata = {
transport: "relay", transport: "relay",
externalSessionKey: `session:${connectionId}`,
}; };
if (daemonKeyPair) { if (daemonKeyPair) {
void attachEncryptedSocket( 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', GIT_OPTIONAL_LOCKS: '0',
} }
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>() const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>()
let restartRequested = false
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0] const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]
const RESTART_EXIT_DELAY_MS = 250
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150 const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000 const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000
const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024 const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024
@@ -346,6 +344,7 @@ export type SessionOptions = {
clientId: string clientId: string
onMessage: (msg: SessionOutboundMessage) => void onMessage: (msg: SessionOutboundMessage) => void
onBinaryMessage?: (frame: BinaryMuxFrame) => void onBinaryMessage?: (frame: BinaryMuxFrame) => void
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void
logger: pino.Logger logger: pino.Logger
downloadTokenStore: DownloadTokenStore downloadTokenStore: DownloadTokenStore
pushTokenStore: PushTokenStore pushTokenStore: PushTokenStore
@@ -379,6 +378,19 @@ export type SessionOptions = {
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap
} }
export type SessionLifecycleIntent =
| {
type: 'shutdown'
clientId: string
requestId: string
}
| {
type: 'restart'
clientId: string
requestId: string
reason?: string
}
type VoiceFeatureUnavailableContext = { type VoiceFeatureUnavailableContext = {
reasonCode: SpeechReadinessSnapshot['voiceFeature']['reasonCode'] reasonCode: SpeechReadinessSnapshot['voiceFeature']['reasonCode']
message: string message: string
@@ -480,6 +492,7 @@ export class Session {
private readonly sessionId: string private readonly sessionId: string
private readonly onMessage: (msg: SessionOutboundMessage) => void private readonly onMessage: (msg: SessionOutboundMessage) => void
private readonly onBinaryMessage: ((frame: BinaryMuxFrame) => void) | null private readonly onBinaryMessage: ((frame: BinaryMuxFrame) => void) | null
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null
private readonly sessionLogger: pino.Logger private readonly sessionLogger: pino.Logger
private readonly paseoHome: string private readonly paseoHome: string
@@ -581,6 +594,7 @@ export class Session {
clientId, clientId,
onMessage, onMessage,
onBinaryMessage, onBinaryMessage,
onLifecycleIntent,
logger, logger,
downloadTokenStore, downloadTokenStore,
pushTokenStore, pushTokenStore,
@@ -600,6 +614,7 @@ export class Session {
this.sessionId = uuidv4() this.sessionId = uuidv4()
this.onMessage = onMessage this.onMessage = onMessage
this.onBinaryMessage = onBinaryMessage ?? null this.onBinaryMessage = onBinaryMessage ?? null
this.onLifecycleIntent = onLifecycleIntent ?? null
this.downloadTokenStore = downloadTokenStore this.downloadTokenStore = downloadTokenStore
this.pushTokenStore = pushTokenStore this.pushTokenStore = pushTokenStore
this.paseoHome = paseoHome this.paseoHome = paseoHome
@@ -717,10 +732,6 @@ export class Session {
} }
if (snapshot.lifecycle !== 'running' && !snapshot.pendingRun) { if (snapshot.lifecycle !== 'running' && !snapshot.pendingRun) {
this.sessionLogger.debug(
{ agentId, lifecycle: snapshot.lifecycle, pendingRun: Boolean(snapshot.pendingRun) },
'interruptAgentIfRunning: not running, skipping'
)
return return
} }
@@ -768,8 +779,6 @@ export class Session {
prompt: AgentPromptInput, prompt: AgentPromptInput,
runOptions?: AgentRunOptions runOptions?: AgentRunOptions
): { ok: true } | { ok: false; error: string } { ): { ok: true } | { ok: false; error: string } {
this.sessionLogger.info({ agentId }, `Starting agent stream for ${agentId}`)
let iterator: AsyncGenerator<AgentStreamEvent> let iterator: AsyncGenerator<AgentStreamEvent>
try { try {
iterator = this.agentManager.streamAgent(agentId, prompt, runOptions) iterator = this.agentManager.streamAgent(agentId, prompt, runOptions)
@@ -1360,6 +1369,10 @@ export class Session {
await this.handleRestartServerRequest(msg.requestId, msg.reason) await this.handleRestartServerRequest(msg.requestId, msg.reason)
break break
case 'shutdown_server_request':
await this.handleShutdownServerRequest(msg.requestId)
break
case 'fetch_agent_timeline_request': case 'fetch_agent_timeline_request':
await this.handleFetchAgentTimelineRequest(msg) await this.handleFetchAgentTimelineRequest(msg)
break break
@@ -1634,12 +1647,6 @@ export class Session {
} }
private async handleRestartServerRequest(requestId: string, reason?: string): Promise<void> { 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> = { const payload: { status: string } & Record<string, unknown> = {
status: 'restart_requested', status: 'restart_requested',
clientId: this.clientId, clientId: this.clientId,
@@ -1655,17 +1662,41 @@ export class Session {
payload, payload,
}) })
if (typeof process.send === 'function') { this.emitLifecycleIntent({
process.send({ type: 'restart',
type: 'paseo:restart', clientId: this.clientId,
requestId,
...(reason ? { reason } : {}), ...(reason ? { reason } : {}),
}) })
return
} }
setTimeout(() => { private async handleShutdownServerRequest(requestId: string): Promise<void> {
process.exit(0) this.sessionLogger.warn('Shutdown requested via websocket')
}, RESTART_EXIT_DELAY_MS) 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> { 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 }) const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { labels })
await this.forwardAgentUpdate(snapshot) 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) { if (requestId) {
const agentPayload = await this.getAgentPayloadById(snapshot.id) const agentPayload = await this.getAgentPayloadById(snapshot.id)
if (!agentPayload) { 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) { if (worktreeConfig) {
void runAsyncWorktreeBootstrap({ void runAsyncWorktreeBootstrap({
agentId: snapshot.id, agentId: snapshot.id,
@@ -3336,10 +3365,6 @@ export class Session {
*/ */
private async handleClearAgentAttention(agentId: string | string[]): Promise<void> { private async handleClearAgentAttention(agentId: string | string[]): Promise<void> {
const agentIds = Array.isArray(agentId) ? agentId : [agentId] const agentIds = Array.isArray(agentId) ? agentId : [agentId]
this.sessionLogger.debug(
{ agentIds },
`Clearing attention for ${agentIds.length} agent(s): ${agentIds.join(', ')}`
)
try { try {
await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id))) await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id)))
@@ -4580,11 +4605,6 @@ export class Session {
private async handleFileExplorerRequest(request: FileExplorerRequest): Promise<void> { private async handleFileExplorerRequest(request: FileExplorerRequest): Promise<void> {
const { agentId, path: requestedPath = '.', mode, requestId } = request const { agentId, path: requestedPath = '.', mode, requestId } = request
this.sessionLogger.debug(
{ agentId, mode, path: requestedPath },
`Handling file explorer request for agent ${agentId} (${mode} ${requestedPath})`
)
try { try {
const agents = this.agentManager.listAgents() const agents = this.agentManager.listAgents()
const agent = agents.find((a) => a.id === agentId) const agent = agents.find((a) => a.id === agentId)
@@ -6211,7 +6231,6 @@ export class Session {
if (this.agentMcpClient) { if (this.agentMcpClient) {
try { try {
await this.agentMcpClient.close() await this.agentMcpClient.close()
this.sessionLogger.debug('Agent MCP client closed')
} catch (error) { } catch (error) {
this.sessionLogger.error({ err: error }, 'Failed to close Agent MCP client') 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) const existingStreamId = this.terminalStreamByTerminalId.get(msg.terminalId)
if (typeof existingStreamId === 'number') { 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() const streamId = this.allocateTerminalStreamId()
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { TerminalManager } from "./terminal-manager.js"; import { TerminalManager } from "./terminal-manager.js";
import { findSessionByName, killSession } from "./tmux.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; 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"; 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. * Sets up an isolated Claude config directory for testing.
* Creates a temp directory with: * 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. * Returns a cleanup function that restores the original env and removes the temp dir.
*/ */
export function useTempClaudeConfigDir(): () => void { export function useTempClaudeConfigDir(): () => void {
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
const configDir = mkdtempSync(path.join(tmpdir(), "claude-config-")); const configDir = mkdtempSync(path.join(tmpdir(), "claude-config-"));
const settings = { const settings = {
permissions: { permissions: {
@@ -40,13 +72,9 @@ export function useTempClaudeConfigDir(): () => void {
writeFileSync(path.join(configDir, "settings.json"), settingsText, "utf8"); writeFileSync(path.join(configDir, "settings.json"), settingsText, "utf8");
writeFileSync(path.join(configDir, "settings.local.json"), settingsText, "utf8"); writeFileSync(path.join(configDir, "settings.local.json"), settingsText, "utf8");
seedClaudeAuth(configDir); seedClaudeAuth(configDir);
process.env.CLAUDE_CONFIG_DIR = configDir; activateClaudeConfigDir(configDir);
return () => { return () => {
if (previousConfigDir === undefined) { deactivateClaudeConfigDir(configDir);
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
}
try { try {
rmSync(configDir, { recursive: true, force: true }); rmSync(configDir, { recursive: true, force: true });
} catch (error) { } 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( export async function createTestPaseoDaemon(
options: TestPaseoDaemonOptions = {} options: TestPaseoDaemonOptions = {}
): Promise<TestPaseoDaemon> { ): Promise<TestPaseoDaemon> {
const maxAttempts = 5; const maxAttempts = 8;
let lastError: unknown; let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) { for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
@@ -91,7 +119,7 @@ export async function createTestPaseoDaemon(
const logger = options.logger ?? pino({ level: "silent" }); const logger = options.logger ?? pino({ level: "silent" });
const daemon = await createPaseoDaemon(config, logger); const daemon = await createPaseoDaemon(config, logger);
try { try {
await daemon.start(); await startDaemonWithTimeout(daemon, TEST_DAEMON_START_TIMEOUT_MS);
const close = async (): Promise<void> => { const close = async (): Promise<void> => {
await daemon.stop().catch(() => undefined); 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(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
await rm(staticDir, { 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; throw error;
} }
} }
@@ -133,3 +164,11 @@ function isAddressInUseError(error: unknown): boolean {
const record = error as { code?: string }; const record = error as { code?: string };
return record.code === "EADDRINUSE"; 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"; } from "../shared/binary-mux.js";
import type { AllowedHostsConfig } from "./allowed-hosts.js"; import type { AllowedHostsConfig } from "./allowed-hosts.js";
import { isHostAllowed } 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 { AgentProvider } from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
@@ -53,6 +53,7 @@ import {
export type AgentMcpTransportFactory = () => Promise<Transport>; export type AgentMcpTransportFactory = () => Promise<Transport>;
export type ExternalSocketMetadata = { export type ExternalSocketMetadata = {
transport: "relay"; transport: "relay";
externalSessionKey?: string;
}; };
type PendingConnection = { type PendingConnection = {
@@ -216,6 +217,7 @@ export class VoiceAssistantWebSocketServer {
>(); >();
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>(); private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private serverCapabilities: ServerCapabilities | undefined; private serverCapabilities: ServerCapabilities | undefined;
constructor( constructor(
@@ -248,7 +250,8 @@ export class VoiceAssistantWebSocketServer {
getSpeechReadiness?: () => SpeechReadinessSnapshot; getSpeechReadiness?: () => SpeechReadinessSnapshot;
}, },
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap, agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap,
daemonVersion?: string daemonVersion?: string,
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void
) { ) {
this.logger = logger.child({ module: "websocket-server" }); this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId; this.serverId = serverId;
@@ -267,6 +270,7 @@ export class VoiceAssistantWebSocketServer {
this.voice = voice ?? null; this.voice = voice ?? null;
this.dictation = dictation ?? null; this.dictation = dictation ?? null;
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
this.onLifecycleIntent = onLifecycleIntent ?? null;
this.serverCapabilities = buildServerCapabilities({ this.serverCapabilities = buildServerCapabilities({
readiness: this.dictation?.getSpeechReadiness?.() ?? null, readiness: this.dictation?.getSpeechReadiness?.() ?? null,
}); });
@@ -522,6 +526,9 @@ export class VoiceAssistantWebSocketServer {
} }
this.sendBinaryToConnection(connection, frame); this.sendBinaryToConnection(connection, frame);
}, },
onLifecycleIntent: (intent) => {
this.onLifecycleIntent?.(intent);
},
logger: connectionLogger.child({ module: "session" }), logger: connectionLogger.child({ module: "session" }),
downloadTokenStore: this.downloadTokenStore, downloadTokenStore: this.downloadTokenStore,
pushTokenStore: this.pushTokenStore, pushTokenStore: this.pushTokenStore,
@@ -45,6 +45,24 @@ describe('shared messages stream parsing', () => {
expect(parsed.payload.entries[0]?.item.type).toBe('assistant_message') 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', () => { it('parses representative agent_stream tool_call event', () => {
const parsed = AgentStreamMessageSchema.parse({ const parsed = AgentStreamMessageSchema.parse({
type: 'agent_stream', type: 'agent_stream',
+14
View File
@@ -648,6 +648,11 @@ export const RestartServerRequestMessageSchema = z.object({
requestId: z.string(), requestId: z.string(),
}) })
export const ShutdownServerRequestMessageSchema = z.object({
type: z.literal('shutdown_server_request'),
requestId: z.string(),
})
export const AgentTimelineCursorSchema = z.object({ export const AgentTimelineCursorSchema = z.object({
epoch: z.string(), epoch: z.string(),
seq: z.number().int().nonnegative(), seq: z.number().int().nonnegative(),
@@ -1080,6 +1085,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion('type', [
ResumeAgentRequestMessageSchema, ResumeAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema, RefreshAgentRequestMessageSchema,
CancelAgentRequestMessageSchema, CancelAgentRequestMessageSchema,
ShutdownServerRequestMessageSchema,
RestartServerRequestMessageSchema, RestartServerRequestMessageSchema,
FetchAgentTimelineRequestMessageSchema, FetchAgentTimelineRequestMessageSchema,
SetAgentModeRequestMessageSchema, SetAgentModeRequestMessageSchema,
@@ -1351,11 +1357,18 @@ export const RestartRequestedStatusPayloadSchema = z.object({
requestId: z.string(), 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', [ export const KnownStatusPayloadSchema = z.discriminatedUnion('status', [
AgentCreatedStatusPayloadSchema, AgentCreatedStatusPayloadSchema,
AgentCreateFailedStatusPayloadSchema, AgentCreateFailedStatusPayloadSchema,
AgentResumedStatusPayloadSchema, AgentResumedStatusPayloadSchema,
AgentRefreshedStatusPayloadSchema, AgentRefreshedStatusPayloadSchema,
ShutdownRequestedStatusPayloadSchema,
RestartRequestedStatusPayloadSchema, RestartRequestedStatusPayloadSchema,
]) ])
@@ -2187,6 +2200,7 @@ export type ProjectIcon = z.infer<typeof ProjectIconSchema>
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema> export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema> export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema> export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>
export type ShutdownServerRequestMessage = z.infer<typeof ShutdownServerRequestMessageSchema>
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema> export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>
export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema> export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema>
export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema> export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>
+3 -2
View File
@@ -16,8 +16,9 @@ export default defineConfig({
pool: "forks", pool: "forks",
poolOptions: { poolOptions: {
forks: { forks: {
singleFork: false, singleFork: true,
maxForks: 4, minForks: 1,
maxForks: 1,
}, },
}, },
exclude: ["**/node_modules/**", "**/dist/**"], exclude: ["**/node_modules/**", "**/dist/**"],