test: harden daemon lifecycle and e2e reliability across app/cli/server
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
+428
-158
@@ -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,165 +223,293 @@ 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 relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
const cleanup = async () => {
|
||||||
relayProcess = spawn(
|
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
|
||||||
'npx',
|
daemonProcess = null;
|
||||||
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
metroProcess = null;
|
||||||
{
|
relayProcess = null;
|
||||||
cwd: relayDir,
|
|
||||||
env: { ...process.env },
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
detached: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
relayProcess.stdout?.on('data', (data: Buffer) => {
|
|
||||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
|
||||||
for (const line of lines) {
|
|
||||||
console.log(`[relay] ${line}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
relayProcess.stderr?.on('data', (data: Buffer) => {
|
|
||||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
|
||||||
for (const line of lines) {
|
|
||||||
console.error(`[relay] ${line}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitForServer(relayPort, 30000);
|
|
||||||
|
|
||||||
// Start Metro bundler on dynamic port
|
|
||||||
const appDir = path.resolve(__dirname, '..');
|
|
||||||
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
|
|
||||||
cwd: appDir,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BROWSER: 'none', // Don't auto-open browser
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
detached: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
metroProcess.stdout?.on('data', (data: Buffer) => {
|
|
||||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
|
||||||
for (const line of lines) {
|
|
||||||
console.log(`[metro] ${line}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
metroProcess.stderr?.on('data', (data: Buffer) => {
|
|
||||||
console.error(`[metro] ${data.toString().trim()}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
|
|
||||||
const tsxBin = execSync('which tsx').toString().trim();
|
|
||||||
|
|
||||||
let offerPayload: OfferPayload | null = null;
|
|
||||||
let offerResolve: (() => void) | null = null;
|
|
||||||
const offerPromise = new Promise<void>((resolve) => {
|
|
||||||
offerResolve = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
|
||||||
cwd: serverDir,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
PASEO_HOME: paseoHome,
|
|
||||||
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
|
|
||||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
|
||||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
|
||||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
|
||||||
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
|
|
||||||
PASEO_DICTATION_ENABLED: "0",
|
|
||||||
PASEO_VOICE_MODE_ENABLED: "0",
|
|
||||||
NODE_ENV: 'development',
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
detached: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdoutBuffer = '';
|
|
||||||
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
|
||||||
stdoutBuffer += data.toString('utf8');
|
|
||||||
const lines = stdoutBuffer.split('\n');
|
|
||||||
stdoutBuffer = lines.pop() ?? '';
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
if (!offerPayload) {
|
|
||||||
const clean = stripAnsi(trimmed);
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
|
||||||
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
|
|
||||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
|
||||||
offerResolve?.();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
|
||||||
if (match && clean.includes('pairing_offer')) {
|
|
||||||
try {
|
|
||||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
|
||||||
offerResolve?.();
|
|
||||||
} catch {
|
|
||||||
// ignore parsing failures
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`[daemon] ${trimmed}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
|
||||||
console.error(`[daemon] ${data.toString().trim()}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for both daemon and Metro to be ready
|
|
||||||
await Promise.all([
|
|
||||||
waitForServer(port),
|
|
||||||
waitForServer(metroPort, 120000), // Metro can take longer to start
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Wait for daemon to emit a pairing offer (includes relay session ID).
|
|
||||||
await Promise.race([
|
|
||||||
offerPromise,
|
|
||||||
new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
if (!offerPayload) {
|
|
||||||
throw new Error('pairing_offer was not parsed from daemon logs');
|
|
||||||
}
|
|
||||||
const offer = offerPayload as OfferPayload;
|
|
||||||
|
|
||||||
process.env.E2E_DAEMON_PORT = String(port);
|
|
||||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
|
||||||
process.env.E2E_SERVER_ID = offer.serverId;
|
|
||||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
|
||||||
process.env.E2E_METRO_PORT = String(metroPort);
|
|
||||||
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
|
|
||||||
|
|
||||||
return async () => {
|
|
||||||
if (daemonProcess) {
|
|
||||||
daemonProcess.kill('SIGTERM');
|
|
||||||
daemonProcess = null;
|
|
||||||
}
|
|
||||||
if (metroProcess) {
|
|
||||||
metroProcess.kill('SIGTERM');
|
|
||||||
metroProcess = null;
|
|
||||||
}
|
|
||||||
if (relayProcess) {
|
|
||||||
relayProcess.kill('SIGTERM');
|
|
||||||
relayProcess = null;
|
|
||||||
}
|
|
||||||
if (paseoHome) {
|
if (paseoHome) {
|
||||||
await rm(paseoHome, { recursive: true, force: true });
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
paseoHome = null;
|
paseoHome = null;
|
||||||
}
|
}
|
||||||
console.log('[e2e] Test daemon stopped');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||||
|
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech');
|
||||||
|
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||||
|
const dictationProvider = openAiUsable ? 'openai' : 'local';
|
||||||
|
|
||||||
|
if (dictationProvider === 'local' && !hasDefaultLocalModelsDir) {
|
||||||
|
throw new Error(
|
||||||
|
'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' +
|
||||||
|
'Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null;
|
||||||
|
console.log(
|
||||||
|
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
||||||
|
const maxRelayStartupAttempts = 5;
|
||||||
|
let relayStarted = false;
|
||||||
|
let lastRelayStartupError: unknown = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||||
|
relayPort = await getAvailablePort();
|
||||||
|
relayLineBuffer = createLineBuffer();
|
||||||
|
let relayStartupFailureLine: string | null = null;
|
||||||
|
let relayReadyForSelectedPort = false;
|
||||||
|
|
||||||
|
relayProcess = spawn(
|
||||||
|
'npx',
|
||||||
|
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
||||||
|
{
|
||||||
|
cwd: relayDir,
|
||||||
|
env: { ...process.env },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
relayProcess.stdout?.on('data', (data: Buffer) => {
|
||||||
|
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
relayLineBuffer.add(`[stdout] ${line}`);
|
||||||
|
const failure = parseRelayStartupFailure(line);
|
||||||
|
if (failure) {
|
||||||
|
relayStartupFailureLine = failure;
|
||||||
|
}
|
||||||
|
const clean = stripAnsi(line);
|
||||||
|
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
||||||
|
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
||||||
|
relayReadyForSelectedPort = true;
|
||||||
|
}
|
||||||
|
console.log(`[relay] ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
relayProcess.stderr?.on('data', (data: Buffer) => {
|
||||||
|
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
relayLineBuffer.add(`[stderr] ${line}`);
|
||||||
|
const failure = parseRelayStartupFailure(line);
|
||||||
|
if (failure) {
|
||||||
|
relayStartupFailureLine = failure;
|
||||||
|
}
|
||||||
|
const clean = stripAnsi(line);
|
||||||
|
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
||||||
|
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
||||||
|
relayReadyForSelectedPort = true;
|
||||||
|
}
|
||||||
|
console.error(`[relay] ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForServer(relayPort, {
|
||||||
|
label: 'Relay dev server',
|
||||||
|
timeoutMs: 30000,
|
||||||
|
childProcess: relayProcess,
|
||||||
|
getRecentOutput: relayLineBuffer.dump,
|
||||||
|
});
|
||||||
|
|
||||||
|
const readyDeadline = Date.now() + 5000;
|
||||||
|
while (
|
||||||
|
!relayReadyForSelectedPort &&
|
||||||
|
relayStartupFailureLine === null &&
|
||||||
|
relayProcess?.exitCode === null &&
|
||||||
|
relayProcess?.signalCode === null &&
|
||||||
|
Date.now() < readyDeadline
|
||||||
|
) {
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relayStartupFailureLine) {
|
||||||
|
throw new Error(`Relay startup failed: ${relayStartupFailureLine}`);
|
||||||
|
}
|
||||||
|
if (!relayReadyForSelectedPort) {
|
||||||
|
throw new Error(
|
||||||
|
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
||||||
|
relayLineBuffer.dump
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
|
||||||
|
throw new Error(
|
||||||
|
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
|
||||||
|
relayLineBuffer.dump
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
relayStarted = true;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
lastRelayStartupError = error;
|
||||||
|
await stopProcess(relayProcess);
|
||||||
|
relayProcess = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayStarted) {
|
||||||
|
const message =
|
||||||
|
lastRelayStartupError instanceof Error
|
||||||
|
? lastRelayStartupError.message
|
||||||
|
: String(lastRelayStartupError);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Metro bundler on dynamic port
|
||||||
|
const appDir = path.resolve(__dirname, '..');
|
||||||
|
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
|
||||||
|
cwd: appDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BROWSER: 'none', // Don't auto-open browser
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
metroProcess.stdout?.on('data', (data: Buffer) => {
|
||||||
|
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
metroLineBuffer.add(`[stdout] ${line}`);
|
||||||
|
console.log(`[metro] ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
metroProcess.stderr?.on('data', (data: Buffer) => {
|
||||||
|
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 tsxBin = execSync('which tsx').toString().trim();
|
||||||
|
|
||||||
|
let offerPayload: OfferPayload | null = null;
|
||||||
|
let offerResolve: (() => void) | null = null;
|
||||||
|
const offerPromise = new Promise<void>((resolve) => {
|
||||||
|
offerResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
||||||
|
cwd: serverDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PASEO_HOME: paseoHome,
|
||||||
|
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
|
||||||
|
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||||
|
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||||
|
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||||
|
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
|
||||||
|
PASEO_DICTATION_ENABLED: '1',
|
||||||
|
PASEO_VOICE_MODE_ENABLED: '1',
|
||||||
|
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
|
||||||
|
PASEO_VOICE_STT_PROVIDER: 'openai',
|
||||||
|
PASEO_VOICE_TTS_PROVIDER: 'openai',
|
||||||
|
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdoutBuffer = '';
|
||||||
|
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
||||||
|
stdoutBuffer += data.toString('utf8');
|
||||||
|
const lines = stdoutBuffer.split('\n');
|
||||||
|
stdoutBuffer = lines.pop() ?? '';
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
daemonLineBuffer.add(`[stdout] ${trimmed}`);
|
||||||
|
if (!offerPayload) {
|
||||||
|
const clean = stripAnsi(trimmed);
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||||
|
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
|
||||||
|
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||||
|
offerResolve?.();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||||
|
if (match && clean.includes('pairing_offer')) {
|
||||||
|
try {
|
||||||
|
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||||
|
offerResolve?.();
|
||||||
|
} catch {
|
||||||
|
// ignore parsing failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[daemon] ${trimmed}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
||||||
|
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
daemonLineBuffer.add(`[stderr] ${line}`);
|
||||||
|
console.error(`[daemon] ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for both daemon and Metro to be ready
|
||||||
|
await Promise.all([
|
||||||
|
waitForServer(port, {
|
||||||
|
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).
|
||||||
|
await Promise.race([
|
||||||
|
offerPromise,
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (!offerPayload) {
|
||||||
|
throw new Error('pairing_offer was not parsed from daemon logs');
|
||||||
|
}
|
||||||
|
const offer = offerPayload as OfferPayload;
|
||||||
|
|
||||||
|
process.env.E2E_DAEMON_PORT = String(port);
|
||||||
|
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||||
|
process.env.E2E_SERVER_ID = offer.serverId;
|
||||||
|
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||||
|
process.env.E2E_METRO_PORT = String(metroPort);
|
||||||
|
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
await cleanup();
|
||||||
|
console.log('[e2e] Test daemon stopped');
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await cleanup();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+149
-20
@@ -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 providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
const normalizedProvider = provider.trim();
|
||||||
await expect(providerLabel).toBeVisible();
|
if (!normalizedProvider) {
|
||||||
await providerLabel.click();
|
throw new Error('Provider must be a non-empty string.');
|
||||||
|
}
|
||||||
|
|
||||||
const option = page.getByText(provider, { exact: true }).first();
|
const providerTrigger = page.getByTestId('draft-provider-select').first();
|
||||||
|
if (
|
||||||
|
await providerTrigger
|
||||||
|
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||||
|
await providerTrigger.click();
|
||||||
|
} else {
|
||||||
|
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
||||||
|
await expect(providerLabel).toBeVisible();
|
||||||
|
await providerLabel.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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 modelLabel = page.getByText('MODEL', { exact: true }).first();
|
const normalizedModel = model.trim();
|
||||||
await expect(modelLabel).toBeVisible();
|
if (!normalizedModel) {
|
||||||
await modelLabel.click();
|
throw new Error('Model must be a non-empty string.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelTrigger = page.getByTestId('draft-model-select').first();
|
||||||
|
if (
|
||||||
|
await modelTrigger
|
||||||
|
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||||
|
await modelTrigger.click();
|
||||||
|
} else {
|
||||||
|
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
||||||
|
await expect(modelLabel).toBeVisible();
|
||||||
|
await modelLabel.click();
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for the model dropdown to open
|
// 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 modeLabel = page.getByText('MODE', { exact: true }).first();
|
const modeTrigger = page.getByTestId('draft-mode-select').first();
|
||||||
await expect(modeLabel).toBeVisible();
|
if (await modeTrigger.isVisible().catch(() => false)) {
|
||||||
await modeLabel.click();
|
await modeTrigger.click();
|
||||||
|
} else {
|
||||||
|
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
||||||
|
await expect(modeLabel).toBeVisible();
|
||||||
|
await modeLabel.click();
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for the mode dropdown to open
|
// 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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 page.getByText('+ Add connection', { exact: true }).click();
|
await expect
|
||||||
await page.getByText('Direct connection', { exact: true }).click();
|
.poll(
|
||||||
|
async () => {
|
||||||
|
if (await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false)) {
|
||||||
|
return 'welcome';
|
||||||
|
}
|
||||||
|
if (await page.getByText('+ Add connection', { exact: true }).isVisible().catch(() => false)) {
|
||||||
|
return 'settings';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
{ timeout: 15000 }
|
||||||
|
)
|
||||||
|
.not.toBe('');
|
||||||
|
|
||||||
|
const isWelcome = await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false);
|
||||||
|
if (isWelcome) {
|
||||||
|
await page.getByText('Direct connection', { exact: true }).first().click();
|
||||||
|
} else {
|
||||||
|
await page.getByText('+ Add connection', { exact: true }).click();
|
||||||
|
await page.getByText('Direct connection', { exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
const input = page.getByPlaceholder('host:6767');
|
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 }) => {
|
||||||
|
|||||||
@@ -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))}`;
|
||||||
|
|
||||||
await page.getByText('+ Add connection', { exact: true }).click();
|
const welcomeTitle = page.getByText('Welcome to Paseo', { exact: true });
|
||||||
await page.getByText('Paste pairing link', { exact: true }).click();
|
if (await welcomeTitle.isVisible().catch(() => false)) {
|
||||||
|
await page.getByTestId('welcome-paste-pairing-link').click();
|
||||||
|
} else {
|
||||||
|
await page.getByText('+ Add connection', { exact: true }).click();
|
||||||
|
await page.getByText('Paste pairing link', { exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
|
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 }) => {
|
||||||
|
|||||||
@@ -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,26 +26,13 @@ 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;
|
return true;
|
||||||
target = container;
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 openTerminalsPanel(page: Page): Promise<void> {
|
async function ensureExplorerTabsVisible(page: Page): Promise<void> {
|
||||||
let header = page.locator('[data-testid="explorer-header"]:visible').first();
|
const filesTab = visibleTestId(page, "explorer-tab-files");
|
||||||
if (!(await header.isVisible().catch(() => false))) {
|
if (await filesTab.isVisible().catch(() => false)) {
|
||||||
const toggle = page.getByRole("button", {
|
return;
|
||||||
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 toggle = page
|
||||||
await expect(header).toBeVisible({ timeout: 30000 });
|
.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 });
|
||||||
|
}
|
||||||
|
|
||||||
const terminalsTab = page.getByTestId("explorer-tab-terminals").first();
|
async function openTerminalsPanel(page: Page): Promise<void> {
|
||||||
|
await ensureExplorerTabsVisible(page);
|
||||||
|
|
||||||
|
const terminalsTab = visibleTestId(page, "explorer-tab-terminals");
|
||||||
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);
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,65 +28,269 @@ 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;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
domElement.scrollIntoView({
|
||||||
|
block: "end",
|
||||||
|
behavior: params.animated ? "smooth" : "auto",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStreamRenderStrategy(
|
||||||
|
config: StreamRenderStrategyConfig
|
||||||
|
): StreamRenderStrategy {
|
||||||
|
return {
|
||||||
|
orderTail: (streamItems) =>
|
||||||
|
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
|
||||||
|
orderHead: (streamHead) =>
|
||||||
|
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
|
||||||
|
getNeighborIndex: (index, relation) =>
|
||||||
|
relation === "above"
|
||||||
|
? index + config.assistantTurnTraversalStep
|
||||||
|
: index - config.assistantTurnTraversalStep,
|
||||||
|
getNeighborItem: (items, index, relation) => {
|
||||||
|
const neighborIndex =
|
||||||
|
relation === "above"
|
||||||
|
? index + config.assistantTurnTraversalStep
|
||||||
|
: index - config.assistantTurnTraversalStep;
|
||||||
|
if (neighborIndex < 0 || neighborIndex >= items.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return items[neighborIndex];
|
||||||
|
},
|
||||||
|
collectAssistantTurnContent: (items, startIndex) => {
|
||||||
|
const messages: string[] = [];
|
||||||
|
for (
|
||||||
|
let index = startIndex;
|
||||||
|
index >= 0 && index < items.length;
|
||||||
|
index += config.assistantTurnTraversalStep
|
||||||
|
) {
|
||||||
|
const currentItem = items[index];
|
||||||
|
if (currentItem.kind === "user_message") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (currentItem.kind === "assistant_message") {
|
||||||
|
messages.push(currentItem.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages.reverse().join("\n\n");
|
||||||
|
},
|
||||||
|
isNearBottom: (input) => config.isNearBottom(input),
|
||||||
|
getBottomOffset: (metrics) => config.getBottomOffset(metrics),
|
||||||
|
getEdgeSlotProps: (component, gapSize) => {
|
||||||
|
if (config.edgeSlot === "header") {
|
||||||
|
return {
|
||||||
|
ListHeaderComponent: component,
|
||||||
|
ListHeaderComponentStyle: { marginBottom: gapSize },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ListFooterComponent: component,
|
||||||
|
ListFooterComponentStyle: { marginTop: gapSize },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
|
||||||
|
getFlatListInverted: () => config.flatListInverted,
|
||||||
|
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
|
||||||
|
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
|
||||||
|
config.disableParentScrollOnInlineDetailsExpansion,
|
||||||
|
shouldAnchorBottomOnContentSizeChange: () =>
|
||||||
|
config.anchorBottomOnContentSizeChange,
|
||||||
|
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
|
||||||
|
shouldUseVirtualizedList: () => config.useVirtualizedList,
|
||||||
|
scrollToBottom: (params) => config.scrollToBottom(params),
|
||||||
|
scrollToOffset: (params) => config.scrollToOffset(params),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInvertedStreamStrategy(): StreamRenderStrategy {
|
||||||
|
return createStreamRenderStrategy({
|
||||||
|
orderTailReverse: true,
|
||||||
|
orderHeadReverse: true,
|
||||||
|
assistantTurnTraversalStep: 1,
|
||||||
|
edgeSlot: "header",
|
||||||
|
flatListInverted: true,
|
||||||
|
overlayScrollbarInverted: true,
|
||||||
|
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
|
||||||
|
disableParentScrollOnInlineDetailsExpansion: false,
|
||||||
|
anchorBottomOnContentSizeChange: false,
|
||||||
|
animateManualScrollToBottom: true,
|
||||||
|
useVirtualizedList: true,
|
||||||
|
isNearBottom: (input) => input.offsetY <= input.threshold,
|
||||||
|
getBottomOffset: () => 0,
|
||||||
|
scrollToBottom: ({ refs, animated }) => {
|
||||||
|
refs.flatListRef.current?.scrollToOffset({
|
||||||
|
offset: 0,
|
||||||
|
animated,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
scrollToOffset: ({ refs, offset, animated }) => {
|
||||||
|
refs.flatListRef.current?.scrollToOffset({ offset, animated });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createForwardStreamStrategy(): StreamRenderStrategy {
|
||||||
|
return createStreamRenderStrategy({
|
||||||
|
orderTailReverse: false,
|
||||||
|
orderHeadReverse: false,
|
||||||
|
assistantTurnTraversalStep: -1,
|
||||||
|
edgeSlot: "footer",
|
||||||
|
flatListInverted: false,
|
||||||
|
overlayScrollbarInverted: false,
|
||||||
|
maintainVisibleContentPosition: undefined,
|
||||||
|
disableParentScrollOnInlineDetailsExpansion: false,
|
||||||
|
anchorBottomOnContentSizeChange: true,
|
||||||
|
animateManualScrollToBottom: false,
|
||||||
|
useVirtualizedList: false,
|
||||||
|
isNearBottom: (inputMetrics) => {
|
||||||
|
const distanceFromBottom = Math.max(
|
||||||
|
0,
|
||||||
|
inputMetrics.contentHeight -
|
||||||
|
(inputMetrics.offsetY + inputMetrics.viewportHeight)
|
||||||
|
);
|
||||||
|
return distanceFromBottom <= inputMetrics.threshold;
|
||||||
|
},
|
||||||
|
getBottomOffset: (metrics) =>
|
||||||
|
Math.max(0, metrics.contentHeight - metrics.viewportHeight),
|
||||||
|
scrollToBottom: ({ refs, metrics, animated }) => {
|
||||||
|
if (scrollAnchorIntoView({ refs, animated })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
|
||||||
|
refs.scrollViewRef.current?.scrollTo?.({
|
||||||
|
y: Math.max(0, metrics.contentHeight - metrics.viewportHeight),
|
||||||
|
animated,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
scrollToOffset: ({ refs, offset, animated }) => {
|
||||||
|
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveStreamRenderStrategy(
|
export function resolveStreamRenderStrategy(
|
||||||
input: ResolveStreamRenderStrategyInput
|
input: ResolveStreamRenderStrategyInput
|
||||||
): StreamRenderStrategy {
|
): StreamRenderStrategy {
|
||||||
if (input.platform === "web") {
|
if (input.platform === "web") {
|
||||||
return input.isMobileBreakpoint
|
return createForwardStreamStrategy();
|
||||||
? FORWARD_STREAM_STRATEGY_MOBILE
|
|
||||||
: FORWARD_STREAM_STRATEGY_DESKTOP;
|
|
||||||
}
|
}
|
||||||
return INVERTED_STREAM_STRATEGY;
|
return createInvertedStreamStrategy();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function orderTailForStreamRenderStrategy(params: {
|
export function orderTailForStreamRenderStrategy(params: {
|
||||||
strategy: StreamRenderStrategy;
|
strategy: StreamRenderStrategy;
|
||||||
streamItems: StreamItem[];
|
streamItems: StreamItem[];
|
||||||
}): StreamItem[] {
|
}): StreamItem[] {
|
||||||
const { strategy, streamItems } = params;
|
return params.strategy.orderTail(params.streamItems);
|
||||||
return strategy.kind === "inverted_stream"
|
|
||||||
? [...streamItems].reverse()
|
|
||||||
: streamItems;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function orderHeadForStreamRenderStrategy(params: {
|
export function orderHeadForStreamRenderStrategy(params: {
|
||||||
strategy: StreamRenderStrategy;
|
strategy: StreamRenderStrategy;
|
||||||
streamHead: StreamItem[];
|
streamHead: StreamItem[];
|
||||||
}): StreamItem[] {
|
}): StreamItem[] {
|
||||||
const { strategy, streamHead } = params;
|
return params.strategy.orderHead(params.streamHead);
|
||||||
return strategy.kind === "inverted_stream"
|
|
||||||
? [...streamHead].reverse()
|
|
||||||
: streamHead;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStreamNeighborIndex(params: {
|
export function getStreamNeighborIndex(params: {
|
||||||
@@ -132,11 +298,7 @@ export function getStreamNeighborIndex(params: {
|
|||||||
index: number;
|
index: number;
|
||||||
relation: NeighborRelation;
|
relation: NeighborRelation;
|
||||||
}): number {
|
}): number {
|
||||||
const { strategy, index, relation } = params;
|
return params.strategy.getNeighborIndex(params.index, params.relation);
|
||||||
if (strategy.kind === "inverted_stream") {
|
|
||||||
return relation === "above" ? index + 1 : index - 1;
|
|
||||||
}
|
|
||||||
return relation === "above" ? index - 1 : index + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStreamNeighborItem(params: {
|
export function getStreamNeighborItem(params: {
|
||||||
@@ -145,11 +307,11 @@ export function getStreamNeighborItem(params: {
|
|||||||
index: number;
|
index: number;
|
||||||
relation: NeighborRelation;
|
relation: NeighborRelation;
|
||||||
}): StreamItem | undefined {
|
}): StreamItem | undefined {
|
||||||
const nextIndex = getStreamNeighborIndex(params);
|
return params.strategy.getNeighborItem(
|
||||||
if (nextIndex < 0 || nextIndex >= params.items.length) {
|
params.items,
|
||||||
return undefined;
|
params.index,
|
||||||
}
|
params.relation
|
||||||
return params.items[nextIndex];
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
||||||
@@ -157,49 +319,32 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
|||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
}): string {
|
}): string {
|
||||||
const { strategy, items, startIndex } = params;
|
return params.strategy.collectAssistantTurnContent(
|
||||||
const messages: string[] = [];
|
params.items,
|
||||||
|
params.startIndex
|
||||||
for (
|
);
|
||||||
let index = startIndex;
|
|
||||||
index >= 0 && index < items.length;
|
|
||||||
index += strategy.assistantTurnTraversalStep
|
|
||||||
) {
|
|
||||||
const currentItem = items[index];
|
|
||||||
if (currentItem.kind === "user_message") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (currentItem.kind === "assistant_message") {
|
|
||||||
messages.push(currentItem.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages.reverse().join("\n\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isNearBottomForStreamRenderStrategy(
|
export function isNearBottomForStreamRenderStrategy(
|
||||||
params: StreamNearBottomInput & { strategy: StreamRenderStrategy }
|
params: StreamNearBottomInput & { strategy: StreamRenderStrategy }
|
||||||
): boolean {
|
): boolean {
|
||||||
const { strategy, threshold, offsetY } = params;
|
return params.strategy.isNearBottom({
|
||||||
if (strategy.kind === "inverted_stream") {
|
offsetY: params.offsetY,
|
||||||
return offsetY <= threshold;
|
threshold: params.threshold,
|
||||||
}
|
contentHeight: params.contentHeight,
|
||||||
|
viewportHeight: params.viewportHeight,
|
||||||
const distanceFromBottom = Math.max(
|
});
|
||||||
0,
|
|
||||||
params.contentHeight - (offsetY + params.viewportHeight)
|
|
||||||
);
|
|
||||||
return distanceFromBottom <= threshold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBottomOffsetForStreamRenderStrategy(params: StreamViewportMetrics & {
|
export function getBottomOffsetForStreamRenderStrategy(
|
||||||
strategy: StreamRenderStrategy;
|
params: StreamViewportMetrics & {
|
||||||
}): number {
|
strategy: StreamRenderStrategy;
|
||||||
const { strategy } = params;
|
|
||||||
if (strategy.kind === "inverted_stream") {
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
return Math.max(0, params.contentHeight - params.viewportHeight);
|
): number {
|
||||||
|
return params.strategy.getBottomOffset({
|
||||||
|
contentHeight: params.contentHeight,
|
||||||
|
viewportHeight: params.viewportHeight,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStreamEdgeSlotProps(params: {
|
export function getStreamEdgeSlotProps(params: {
|
||||||
@@ -207,15 +352,5 @@ export function getStreamEdgeSlotProps(params: {
|
|||||||
component: ReactElement | ComponentType<any> | null;
|
component: ReactElement | ComponentType<any> | null;
|
||||||
gapSize: number;
|
gapSize: number;
|
||||||
}): StreamEdgeSlotProps {
|
}): StreamEdgeSlotProps {
|
||||||
const { strategy, component, gapSize } = params;
|
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
|
||||||
if (strategy.edgeSlot === "header") {
|
|
||||||
return {
|
|
||||||
ListHeaderComponent: component,
|
|
||||||
ListHeaderComponentStyle: { marginBottom: gapSize },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ListFooterComponent: component,
|
|
||||||
ListFooterComponentStyle: { marginTop: gapSize },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,51 +909,77 @@ 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()}
|
||||||
/>
|
/>
|
||||||
</MessageOuterSpacingProvider>
|
) : (
|
||||||
<WebDesktopScrollbarOverlay
|
<ScrollView
|
||||||
enabled={showDesktopWebScrollbar}
|
ref={scrollViewRef}
|
||||||
metrics={streamScrollbarMetrics}
|
testID="agent-chat-scroll"
|
||||||
inverted={streamRenderStrategy.overlayScrollbarInverted}
|
contentContainerStyle={listContentContainerStyle}
|
||||||
onScrollToOffset={(nextOffset) => {
|
style={stylesheet.list}
|
||||||
flatListRef.current?.scrollToOffset({
|
onLayout={handleListLayout}
|
||||||
offset: nextOffset,
|
onScroll={handleScroll}
|
||||||
animated: false,
|
scrollEventThrottle={16}
|
||||||
});
|
onContentSizeChange={handleContentSizeChange}
|
||||||
}}
|
scrollEnabled={streamScrollEnabled}
|
||||||
/>
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
|
|
||||||
{/* Scroll to bottom button */}
|
|
||||||
{!isNearBottom && (
|
|
||||||
<Animated.View
|
|
||||||
style={stylesheet.scrollToBottomContainer}
|
|
||||||
entering={scrollIndicatorFadeIn}
|
|
||||||
exiting={scrollIndicatorFadeOut}
|
|
||||||
>
|
>
|
||||||
<View style={stylesheet.scrollToBottomInner}>
|
{headerEdgeContent ? (
|
||||||
<Pressable
|
<View style={listEdgeSlotProps.ListHeaderComponentStyle}>
|
||||||
style={stylesheet.scrollToBottomButton}
|
{headerEdgeContent}
|
||||||
onPress={scrollToBottom}
|
</View>
|
||||||
>
|
) : null}
|
||||||
<ChevronDown
|
{nonVirtualizedItems}
|
||||||
size={24}
|
{flatListData.length === 0 ? listEmptyComponent : null}
|
||||||
color={stylesheet.scrollToBottomIcon.color}
|
{footerEdgeContent ? (
|
||||||
/>
|
<View style={listEdgeSlotProps.ListFooterComponentStyle}>
|
||||||
</Pressable>
|
{footerEdgeContent}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
) : null}
|
||||||
|
<View ref={bottomAnchorRef} collapsable={false} />
|
||||||
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
</View>
|
</MessageOuterSpacingProvider>
|
||||||
|
<WebDesktopScrollbarOverlay
|
||||||
|
enabled={showDesktopWebScrollbar}
|
||||||
|
metrics={streamScrollbarMetrics}
|
||||||
|
inverted={streamRenderStrategy.getOverlayScrollbarInverted()}
|
||||||
|
onScrollToOffset={(nextOffset) => {
|
||||||
|
streamRenderStrategy.scrollToOffset({
|
||||||
|
refs: streamRenderRefs,
|
||||||
|
offset: nextOffset,
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scroll to bottom button */}
|
||||||
|
{!isNearBottom && (
|
||||||
|
<Animated.View
|
||||||
|
style={stylesheet.scrollToBottomContainer}
|
||||||
|
entering={scrollIndicatorFadeIn}
|
||||||
|
exiting={scrollIndicatorFadeOut}
|
||||||
|
>
|
||||||
|
<View style={stylesheet.scrollToBottomInner}>
|
||||||
|
<Pressable
|
||||||
|
style={stylesheet.scrollToBottomButton}
|
||||||
|
onPress={scrollToBottom}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
size={24}
|
||||||
|
color={stylesheet.scrollToBottomIcon.color}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</ToolCallSheetProvider>
|
</ToolCallSheetProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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 }],
|
||||||
|
|||||||
@@ -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 dispatchTerminalInputEntry = useCallback(
|
||||||
|
(entry: PendingTerminalInput): boolean => {
|
||||||
|
if (!client) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalId = selectedTerminalIdRef.current;
|
||||||
|
if (!terminalId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === "data") {
|
||||||
|
client.sendTerminalInput(terminalId, {
|
||||||
|
type: "input",
|
||||||
|
data: entry.data,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoded = encodeTerminalKeyInput(entry.input);
|
||||||
|
if (encoded.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
client.sendTerminalInput(terminalId, {
|
||||||
|
type: "input",
|
||||||
|
data: encoded,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[client]
|
||||||
|
);
|
||||||
|
|
||||||
const flushPendingTerminalInput = useCallback(() => {
|
const flushPendingTerminalInput = useCallback(() => {
|
||||||
if (!client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const currentStreamId = getCurrentActiveStreamId();
|
|
||||||
if (currentStreamId === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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) {
|
||||||
for (const entry of pending) {
|
const entry = queue[sentCount];
|
||||||
if (entry.type === "data") {
|
if (!entry) {
|
||||||
client.sendTerminalStreamInput(currentStreamId, entry.data);
|
break;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
client.sendTerminalStreamKey(currentStreamId, entry.input);
|
if (!dispatchTerminalInputEntry(entry)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sentCount += 1;
|
||||||
}
|
}
|
||||||
}, [client, getCurrentActiveStreamId]);
|
|
||||||
|
if (sentCount > 0) {
|
||||||
|
queue.splice(0, sentCount);
|
||||||
|
}
|
||||||
|
}, [dispatchTerminalInputEntry]);
|
||||||
|
|
||||||
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({
|
||||||
epoch: "epoch-1",
|
cursor: {
|
||||||
endSeq: 42,
|
epoch: "epoch-1",
|
||||||
|
endSeq: 42,
|
||||||
|
},
|
||||||
|
hasLocalTail: false,
|
||||||
|
initialTimelineLimit: 200,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
direction: "tail",
|
||||||
|
limit: 200,
|
||||||
|
projection: "canonical",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses canonical catch-up after the current cursor when local tail exists", () => {
|
||||||
|
expect(
|
||||||
|
__private__.buildInitialTimelineRequest({
|
||||||
|
cursor: {
|
||||||
|
epoch: "epoch-1",
|
||||||
|
endSeq: 42,
|
||||||
|
},
|
||||||
|
hasLocalTail: true,
|
||||||
|
initialTimelineLimit: 200,
|
||||||
})
|
})
|
||||||
).toEqual({
|
).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,15 +445,22 @@ 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 decoded = await decodeAudioData(context, arrayBuffer);
|
const parsedWav = parsePcm16Wav(arrayBuffer);
|
||||||
const floatPcm = decoded.getChannelData(0);
|
if (parsedWav) {
|
||||||
const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000);
|
const floatPcm = int16ToFloat32(parsedWav.samples);
|
||||||
emitPcmSegments(pcm16);
|
const pcm16 = resampleToPcm16(floatPcm, parsedWav.sampleRate, 16000);
|
||||||
|
emitPcmSegments(pcm16);
|
||||||
|
} else if (context) {
|
||||||
|
const decoded = await decodeAudioData(context, arrayBuffer);
|
||||||
|
const floatPcm = decoded.getChannelData(0);
|
||||||
|
const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000);
|
||||||
|
emitPcmSegments(pcm16);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onErrorRef.current?.(err instanceof Error ? err : new Error(String(err)));
|
onErrorRef.current?.(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,28 +487,28 @@ 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)
|
||||||
if (!signaled) {
|
const lifecycleRequested = shutdownAttempt.requested
|
||||||
return {
|
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason
|
||||||
action: 'not_running',
|
let forced = false
|
||||||
home: state.home,
|
if (!lifecycleRequested) {
|
||||||
pid,
|
const signaled = signalProcessSafely(pid, 'SIGTERM')
|
||||||
forced: false,
|
if (!signaled) {
|
||||||
message: 'Daemon process was already stopped',
|
return {
|
||||||
|
action: 'not_running',
|
||||||
|
home: state.home,
|
||||||
|
pid,
|
||||||
|
forced: false,
|
||||||
|
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',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,129 +20,209 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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
|
||||||
console.log('Test 1: provider --help shows subcommands')
|
id: string
|
||||||
const result = await $`npx paseo provider --help`.nothrow()
|
description?: string
|
||||||
assert.strictEqual(result.exitCode, 0, 'provider --help should exit 0')
|
|
||||||
assert(result.stdout.includes('ls'), 'help should mention ls')
|
|
||||||
assert(result.stdout.includes('models'), 'help should mention models')
|
|
||||||
console.log('✓ provider --help shows subcommands\n')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test 2: provider ls lists all providers
|
let claudeModelIdsFromJson: string[] = []
|
||||||
{
|
let claudeModelsFromJson: ProviderModel[] = []
|
||||||
console.log('Test 2: provider ls lists all providers')
|
|
||||||
const result = await $`npx paseo provider ls`.nothrow()
|
const ctx = await createE2ETestContext({ timeout: 120000 })
|
||||||
assert.strictEqual(result.exitCode, 0, 'provider ls should exit 0')
|
|
||||||
assert(result.stdout.includes('claude'), 'output should include claude')
|
async function runProviderModelsJson(
|
||||||
assert(result.stdout.includes('codex'), 'output should include codex')
|
provider: 'claude' | 'codex' | 'opencode'
|
||||||
assert(result.stdout.includes('opencode'), 'output should include opencode')
|
): Promise<ProviderModel[]> {
|
||||||
assert(result.stdout.includes('available'), 'output should show available status')
|
const transientNeedles = ['transport closed', 'timed out', 'timeout', 'socket', 'econn']
|
||||||
console.log('✓ provider ls lists all providers\n')
|
|
||||||
|
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`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test 3: provider ls --json outputs valid JSON
|
try {
|
||||||
{
|
// Test 1: provider --help shows subcommands
|
||||||
console.log('Test 3: provider ls --json outputs valid JSON')
|
{
|
||||||
const result = await $`npx paseo provider ls --json`.nothrow()
|
console.log('Test 1: provider --help shows subcommands')
|
||||||
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
const result = await ctx.paseo(['provider', '--help'])
|
||||||
const data = JSON.parse(result.stdout.trim())
|
assert.strictEqual(result.exitCode, 0, 'provider --help should exit 0')
|
||||||
assert(Array.isArray(data), 'output should be an array')
|
assert(result.stdout.includes('ls'), 'help should mention ls')
|
||||||
assert.strictEqual(data.length, 3, 'should have 3 providers')
|
assert(result.stdout.includes('models'), 'help should mention models')
|
||||||
assert(data.some((p: { provider: string }) => p.provider === 'claude'), 'should include claude')
|
console.log('✓ provider --help shows subcommands\n')
|
||||||
assert(data.some((p: { provider: string }) => p.provider === 'codex'), 'should include codex')
|
}
|
||||||
assert(data.some((p: { provider: string }) => p.provider === 'opencode'), 'should include opencode')
|
|
||||||
console.log('✓ provider ls --json outputs valid JSON\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 4: provider ls --quiet outputs provider names only
|
// Test 2: provider ls lists all providers
|
||||||
{
|
{
|
||||||
console.log('Test 4: provider ls --quiet outputs provider names only')
|
console.log('Test 2: provider ls lists all providers')
|
||||||
const result = await $`npx paseo provider ls --quiet`.nothrow()
|
const result = await ctx.paseo(['provider', 'ls'])
|
||||||
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
assert.strictEqual(result.exitCode, 0, 'provider ls should exit 0')
|
||||||
const lines = result.stdout.trim().split('\n')
|
assert(result.stdout.includes('claude'), 'output should include claude')
|
||||||
assert.strictEqual(lines.length, 3, 'should have 3 lines')
|
assert(result.stdout.includes('codex'), 'output should include codex')
|
||||||
assert(lines.includes('claude'), 'should include claude')
|
assert(result.stdout.includes('opencode'), 'output should include opencode')
|
||||||
assert(lines.includes('codex'), 'should include codex')
|
assert(result.stdout.includes('available'), 'output should show available status')
|
||||||
assert(lines.includes('opencode'), 'should include opencode')
|
console.log('✓ provider ls lists all providers\n')
|
||||||
console.log('✓ provider ls --quiet outputs provider names only\n')
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Test 5: provider models claude lists claude models
|
// Test 3: provider ls --json outputs valid JSON
|
||||||
{
|
{
|
||||||
console.log('Test 5: provider models claude lists claude models')
|
console.log('Test 3: provider ls --json outputs valid JSON')
|
||||||
const result = await $`npx paseo provider models claude`.nothrow()
|
const result = await ctx.paseo(['provider', 'ls', '--json'])
|
||||||
assert.strictEqual(result.exitCode, 0, 'provider models claude should exit 0')
|
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
||||||
assert(result.stdout.includes('claude-sonnet-4-20250514'), 'output should include claude-sonnet-4')
|
const data = JSON.parse(result.stdout.trim())
|
||||||
assert(result.stdout.includes('claude-opus-4-20250514'), 'output should include claude-opus-4')
|
assert(Array.isArray(data), 'output should be an array')
|
||||||
assert(result.stdout.includes('claude-3-5-haiku-20241022'), 'output should include claude-haiku')
|
assert.strictEqual(data.length, 3, 'should have 3 providers')
|
||||||
console.log('✓ provider models claude lists claude models\n')
|
assert(data.some((p: { provider: string }) => p.provider === 'claude'), 'should include claude')
|
||||||
}
|
assert(data.some((p: { provider: string }) => p.provider === 'codex'), 'should include codex')
|
||||||
|
assert(data.some((p: { provider: string }) => p.provider === 'opencode'), 'should include opencode')
|
||||||
|
console.log('✓ provider ls --json outputs valid JSON\n')
|
||||||
|
}
|
||||||
|
|
||||||
// Test 6: provider models codex lists codex models
|
// Test 4: provider ls --quiet outputs provider names only
|
||||||
{
|
{
|
||||||
console.log('Test 6: provider models codex lists codex models')
|
console.log('Test 4: provider ls --quiet outputs provider names only')
|
||||||
const result = await $`npx paseo provider models codex`.nothrow()
|
const result = await ctx.paseo(['provider', 'ls', '--quiet'])
|
||||||
assert.strictEqual(result.exitCode, 0, 'provider models codex should exit 0')
|
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
||||||
assert(result.stdout.includes('o3-mini'), 'output should include o3-mini')
|
const lines = result.stdout.trim().split('\n')
|
||||||
assert(result.stdout.includes('o4-mini'), 'output should include o4-mini')
|
assert.strictEqual(lines.length, 3, 'should have 3 lines')
|
||||||
console.log('✓ provider models codex lists codex models\n')
|
assert(lines.includes('claude'), 'should include claude')
|
||||||
}
|
assert(lines.includes('codex'), 'should include codex')
|
||||||
|
assert(lines.includes('opencode'), 'should include opencode')
|
||||||
|
console.log('✓ provider ls --quiet outputs provider names only\n')
|
||||||
|
}
|
||||||
|
|
||||||
// Test 7: provider models opencode lists opencode models
|
// Test 5: provider models claude lists canonical model aliases
|
||||||
{
|
{
|
||||||
console.log('Test 7: provider models opencode lists opencode models')
|
console.log('Test 5: provider models claude lists canonical model aliases')
|
||||||
const result = await $`npx paseo provider models opencode`.nothrow()
|
const data = await runProviderModelsJson('claude')
|
||||||
assert.strictEqual(result.exitCode, 0, 'provider models opencode should exit 0')
|
assert.strictEqual(data.length, 3, 'should have exactly 3 claude models')
|
||||||
// opencode supports both claude and codex models
|
const byId = new Map(data.map((model) => [model.id, model]))
|
||||||
assert(result.stdout.includes('claude-sonnet-4-20250514'), 'output should include claude models')
|
const ids = [...byId.keys()].sort()
|
||||||
assert(result.stdout.includes('o3-mini'), 'output should include codex models')
|
assert.deepStrictEqual(ids, ['default', 'haiku', 'opus'], 'claude IDs should be default/opus/haiku')
|
||||||
console.log('✓ provider models opencode lists opencode 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 8: provider models unknown fails with error
|
// Test 6: provider models codex includes concrete codex model IDs
|
||||||
{
|
{
|
||||||
console.log('Test 8: provider models unknown fails with error')
|
console.log('Test 6: provider models codex includes concrete codex model IDs')
|
||||||
const result = await $`npx paseo provider models unknown`.nothrow()
|
const data = await runProviderModelsJson('codex')
|
||||||
assert.notStrictEqual(result.exitCode, 0, 'should fail for unknown provider')
|
assert(data.length >= 6, 'codex model list should include current codex lineup')
|
||||||
const output = result.stdout + result.stderr
|
const ids = data.map((m) => m.id)
|
||||||
assert(
|
assert.strictEqual(new Set(ids).size, ids.length, 'codex model IDs should be unique')
|
||||||
output.toLowerCase().includes('unknown') || output.toLowerCase().includes('provider'),
|
assert(ids.includes('gpt-5.3-codex'), 'codex output should include gpt-5.3-codex')
|
||||||
'error should mention unknown provider'
|
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')
|
||||||
console.log('✓ provider models unknown fails with error\n')
|
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 9: provider models --json outputs valid JSON
|
// Test 7: provider models opencode returns namespaced model IDs
|
||||||
{
|
{
|
||||||
console.log('Test 9: provider models --json outputs valid JSON')
|
console.log('Test 7: provider models opencode returns namespaced model IDs')
|
||||||
const result = await $`npx paseo provider models claude --json`.nothrow()
|
const data = await runProviderModelsJson('opencode')
|
||||||
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
assert(data.length >= 3, 'opencode model list should not be empty')
|
||||||
const data = JSON.parse(result.stdout.trim())
|
const ids = data.map((m) => m.id)
|
||||||
assert(Array.isArray(data), 'output should be an array')
|
assert(data.every((m) => m.id.includes('/')), 'opencode model IDs should be provider-namespaced')
|
||||||
assert.strictEqual(data.length, 3, 'should have 3 models for claude')
|
assert(ids.includes('opencode/gpt-5-nano'), 'opencode output should include opencode/gpt-5-nano')
|
||||||
assert(data.every((m: { model: string; id: string }) => m.model && m.id), 'each model should have name and id')
|
assert(ids.includes('openai/o3-mini'), 'opencode output should include openai/o3-mini')
|
||||||
console.log('✓ provider models --json outputs valid JSON\n')
|
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 10: provider models --quiet outputs model IDs only
|
// Test 8: provider models unknown fails with error
|
||||||
{
|
{
|
||||||
console.log('Test 10: provider models --quiet outputs model IDs only')
|
console.log('Test 8: provider models unknown fails with error')
|
||||||
const result = await $`npx paseo provider models claude --quiet`.nothrow()
|
const result = await ctx.paseo(['provider', 'models', 'unknown'])
|
||||||
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
assert.notStrictEqual(result.exitCode, 0, 'should fail for unknown provider')
|
||||||
const lines = result.stdout.trim().split('\n')
|
const output = result.stdout + result.stderr
|
||||||
assert.strictEqual(lines.length, 3, 'should have 3 lines')
|
assert(
|
||||||
assert(lines.includes('claude-sonnet-4-20250514'), 'should include claude-sonnet-4')
|
output.toLowerCase().includes('unknown') || output.toLowerCase().includes('provider'),
|
||||||
assert(lines.includes('claude-opus-4-20250514'), 'should include claude-opus-4')
|
'error should mention unknown provider'
|
||||||
assert(lines.includes('claude-3-5-haiku-20241022'), 'should include claude-haiku')
|
)
|
||||||
console.log('✓ provider models --quiet outputs model IDs only\n')
|
console.log('✓ provider models unknown fails with error\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: provider models --json outputs valid JSON
|
||||||
|
{
|
||||||
|
console.log('Test 9: provider models --json outputs valid JSON')
|
||||||
|
const data = await runProviderModelsJson('claude')
|
||||||
|
assert(Array.isArray(data), 'output should be an array')
|
||||||
|
assert.strictEqual(data.length, 3, 'should have exactly 3 models for claude')
|
||||||
|
assert(data.every((m) => m.model && m.id), 'each model should have name and id')
|
||||||
|
const byId = new Map(data.map((model) => [model.id, model]))
|
||||||
|
const ids = [...byId.keys()].sort()
|
||||||
|
assert.deepStrictEqual(ids, ['default', 'haiku', 'opus'], 'claude JSON IDs should be deterministic')
|
||||||
|
assert.strictEqual(byId.get('default')?.model, 'Sonnet 4.5', 'default claude alias should map to Sonnet 4.5')
|
||||||
|
assert.strictEqual(byId.get('opus')?.model, 'Opus 4.6', 'opus claude alias should map to Opus 4.6')
|
||||||
|
assert.strictEqual(byId.get('haiku')?.model, 'Haiku 4.5', 'haiku claude alias should map to Haiku 4.5')
|
||||||
|
claudeModelIdsFromJson = data.map((m) => m.id)
|
||||||
|
claudeModelsFromJson = data
|
||||||
|
console.log('✓ provider models --json outputs valid JSON\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: provider models --quiet outputs model IDs only
|
||||||
|
{
|
||||||
|
console.log('Test 10: provider models --quiet outputs model IDs only')
|
||||||
|
assert(claudeModelIdsFromJson.length > 0, 'claude model IDs should be captured from --json output')
|
||||||
|
const result = await ctx.paseo(['provider', 'models', 'claude', '--quiet'])
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
||||||
|
const lines = result.stdout.trim().split('\n').filter(Boolean)
|
||||||
|
assert.strictEqual(lines.length, 3, 'should have 3 lines')
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
[...lines].sort(),
|
||||||
|
[...claudeModelIdsFromJson].sort(),
|
||||||
|
'--quiet should print the same model IDs returned by --json'
|
||||||
|
)
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
[...lines].sort(),
|
||||||
|
['default', 'haiku', 'opus'],
|
||||||
|
'--quiet should print canonical claude IDs'
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
claudeModelsFromJson.some((m) => m.id === 'default'),
|
||||||
|
'captured --json output should still include default claude model id'
|
||||||
|
)
|
||||||
|
console.log('✓ provider models --quiet outputs model IDs only\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await ctx.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('=== All provider tests passed ===')
|
console.log('=== All provider tests passed ===')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ===')
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,13 +119,19 @@ 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) {
|
||||||
ctx.daemon.kill()
|
if (typeof ctx.daemon.pid === 'number') {
|
||||||
|
killPidTree(ctx.daemon.pid, 'SIGTERM')
|
||||||
|
await sleep(250)
|
||||||
|
killPidTree(ctx.daemon.pid, 'SIGKILL')
|
||||||
|
} else {
|
||||||
|
ctx.daemon.kill()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await rm(paseoHome, { recursive: true, force: true })
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
await rm(workDir, { recursive: true, force: true })
|
await rm(workDir, { recursive: true, force: true })
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
name: "DaemonRunner",
|
const daemonConfig = loadConfig(paseoHome, { env: workerEnv });
|
||||||
startupMessage: config.devMode
|
|
||||||
? "Starting daemon worker (dev mode, crash restarts enabled)"
|
try {
|
||||||
: "Starting daemon worker (IPC restart enabled)",
|
await acquirePidLock(paseoHome, daemonConfig.listen, {
|
||||||
resolveWorkerEntry: () => workerEntry,
|
ownerPid: process.pid,
|
||||||
workerArgs: config.workerArgs,
|
});
|
||||||
workerEnv: process.env,
|
} catch (error) {
|
||||||
workerExecArgv: resolveWorkerExecArgv(workerEntry),
|
if (error instanceof PidLockError) {
|
||||||
restartOnCrash: config.devMode,
|
process.stderr.write(`${error.message}\n`);
|
||||||
shutdownReasons: ["cli_shutdown"],
|
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",
|
||||||
|
startupMessage: config.devMode
|
||||||
|
? "Starting daemon worker (dev mode, crash restarts enabled)"
|
||||||
|
: "Starting daemon worker (IPC restart enabled)",
|
||||||
|
resolveWorkerEntry: () => workerEntry,
|
||||||
|
workerArgs: config.workerArgs,
|
||||||
|
workerEnv,
|
||||||
|
workerExecArgv: resolveWorkerExecArgv(workerEntry),
|
||||||
|
restartOnCrash: config.devMode,
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { fork, type ChildProcess } from "child_process";
|
import { fork, type ChildProcess } from "child_process";
|
||||||
|
|
||||||
type RestartMessage = {
|
type WorkerLifecycleMessage =
|
||||||
type: "paseo:restart";
|
| {
|
||||||
reason?: string;
|
type: "paseo:shutdown";
|
||||||
};
|
}
|
||||||
|
| {
|
||||||
|
type: "paseo:restart";
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
if (session) {
|
try {
|
||||||
await session.close();
|
if (session) {
|
||||||
|
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);
|
||||||
|
|
||||||
const marker = "CLAUDE_ACK_TOKEN";
|
try {
|
||||||
const result = await session.run(
|
const marker = "CLAUDE_ACK_TOKEN";
|
||||||
`Reply with the exact text ${marker} and then stop.`
|
const result = await session.run(
|
||||||
);
|
`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,26 +319,27 @@ 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);
|
||||||
|
|
||||||
const events = session.stream(
|
try {
|
||||||
"Think step by step about the pros and cons of single-file tests, but only share a short plan."
|
const events = session.stream(
|
||||||
);
|
"Think step by step about the pros and cons of single-file tests, but only share a short plan."
|
||||||
|
);
|
||||||
|
|
||||||
let sawReasoning = false;
|
let sawReasoning = false;
|
||||||
|
|
||||||
for await (const event of events) {
|
for await (const event of events) {
|
||||||
await autoApprove(session, event);
|
await autoApprove(session, event);
|
||||||
if (event.type === "timeline" && event.item.type === "reasoning") {
|
if (event.type === "timeline" && event.item.type === "reasoning") {
|
||||||
sawReasoning = true;
|
sawReasoning = true;
|
||||||
}
|
}
|
||||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expect(sawReasoning).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await closeSessionAndCleanup(session, cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(sawReasoning).toBe(true);
|
|
||||||
|
|
||||||
await session.close();
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
},
|
},
|
||||||
120_000
|
120_000
|
||||||
);
|
);
|
||||||
@@ -421,64 +432,65 @@ 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);
|
||||||
|
|
||||||
const events = session.stream(
|
try {
|
||||||
"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."
|
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."
|
||||||
|
);
|
||||||
|
|
||||||
const timeline: AgentTimelineItem[] = [];
|
const timeline: AgentTimelineItem[] = [];
|
||||||
let completed = false;
|
let completed = false;
|
||||||
|
|
||||||
for await (const event of events) {
|
for await (const event of events) {
|
||||||
await autoApprove(session, event);
|
await autoApprove(session, event);
|
||||||
if (event.type === "timeline") {
|
if (event.type === "timeline") {
|
||||||
timeline.push(event.item);
|
timeline.push(event.item);
|
||||||
}
|
}
|
||||||
if (event.type === "turn_completed") {
|
if (event.type === "turn_completed") {
|
||||||
completed = true;
|
completed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (event.type === "turn_failed") {
|
if (event.type === "turn_failed") {
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toolCalls = timeline.filter(
|
||||||
|
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||||
|
item.type === "tool_call"
|
||||||
|
);
|
||||||
|
const commandEvents = toolCalls.filter(
|
||||||
|
(item) =>
|
||||||
|
item.name.toLowerCase().includes("bash") &&
|
||||||
|
item.name !== "permission_request"
|
||||||
|
);
|
||||||
|
const fileChangeEvent = toolCalls.find((item) => {
|
||||||
|
if (item.detail.type === "write" || item.detail.type === "edit") {
|
||||||
|
return item.detail.filePath.includes("tool-test.txt");
|
||||||
|
}
|
||||||
|
if (item.detail.type === "unknown") {
|
||||||
|
return (
|
||||||
|
rawContainsText(item.detail.input, "tool-test.txt") ||
|
||||||
|
rawContainsText(item.detail.output, "tool-test.txt")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rawContainsText(item.detail, "tool-test.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
const sawPwdCommand = commandEvents.some(
|
||||||
|
(item) => (extractToolCommand(item.detail) ?? "").toLowerCase().includes("pwd") && item.status === "completed"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(completed).toBe(true);
|
||||||
|
expect(toolCalls.length).toBeGreaterThan(0);
|
||||||
|
expect(sawPwdCommand).toBe(true);
|
||||||
|
expect(fileChangeEvent).toBeTruthy();
|
||||||
|
|
||||||
|
const filePath = path.join(cwd, "tool-test.txt");
|
||||||
|
expect(existsSync(filePath)).toBe(true);
|
||||||
|
expect(readFileSync(filePath, "utf8")).toContain("hello world");
|
||||||
|
} finally {
|
||||||
|
await closeSessionAndCleanup(session, cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolCalls = timeline.filter(
|
|
||||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
|
||||||
item.type === "tool_call"
|
|
||||||
);
|
|
||||||
const commandEvents = toolCalls.filter(
|
|
||||||
(item) =>
|
|
||||||
item.name.toLowerCase().includes("bash") &&
|
|
||||||
item.name !== "permission_request"
|
|
||||||
);
|
|
||||||
const fileChangeEvent = toolCalls.find((item) => {
|
|
||||||
if (item.detail.type === "write" || item.detail.type === "edit") {
|
|
||||||
return item.detail.filePath.includes("tool-test.txt");
|
|
||||||
}
|
|
||||||
if (item.detail.type === "unknown") {
|
|
||||||
return (
|
|
||||||
rawContainsText(item.detail.input, "tool-test.txt") ||
|
|
||||||
rawContainsText(item.detail.output, "tool-test.txt")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return rawContainsText(item.detail, "tool-test.txt");
|
|
||||||
});
|
|
||||||
|
|
||||||
const sawPwdCommand = commandEvents.some(
|
|
||||||
(item) => (extractToolCommand(item.detail) ?? "").toLowerCase().includes("pwd") && item.status === "completed"
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(completed).toBe(true);
|
|
||||||
expect(toolCalls.length).toBeGreaterThan(0);
|
|
||||||
expect(sawPwdCommand).toBe(true);
|
|
||||||
expect(fileChangeEvent).toBeTruthy();
|
|
||||||
|
|
||||||
const filePath = path.join(cwd, "tool-test.txt");
|
|
||||||
expect(existsSync(filePath)).toBe(true);
|
|
||||||
expect(readFileSync(filePath, "utf8")).toContain("hello world");
|
|
||||||
|
|
||||||
await session.close();
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
},
|
},
|
||||||
180_000
|
180_000
|
||||||
);
|
);
|
||||||
@@ -775,16 +787,17 @@ 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);
|
||||||
|
|
||||||
const first = await session.run("Respond only with the word alpha.");
|
try {
|
||||||
expect(first.finalText.toLowerCase()).toContain("alpha");
|
const first = await session.run("Respond only with the word alpha.");
|
||||||
|
expect(first.finalText.toLowerCase()).toContain("alpha");
|
||||||
|
|
||||||
const second = await session.run(
|
const second = await session.run(
|
||||||
"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,77 +866,80 @@ 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;
|
||||||
|
|
||||||
// Store a specific word in a file to create history and enable recall
|
try {
|
||||||
const timestamp = Date.now();
|
// Store a specific word in a file to create history and enable recall
|
||||||
const secretWord = `XYZZY${timestamp}PLUGH`;
|
const timestamp = Date.now();
|
||||||
const secretFile = path.join(cwd, "secret.txt");
|
const secretWord = `XYZZY${timestamp}PLUGH`;
|
||||||
const prompt = `Write exactly this word to a file called secret.txt: ${secretWord}. Then respond only with "STORED".`;
|
const secretFile = path.join(cwd, "secret.txt");
|
||||||
|
const prompt = `Write exactly this word to a file called secret.txt: ${secretWord}. Then respond only with "STORED".`;
|
||||||
|
|
||||||
let storedResponse = "";
|
let storedResponse = "";
|
||||||
for await (const event of session.stream(prompt)) {
|
for await (const event of session.stream(prompt)) {
|
||||||
await autoApprove(session, event);
|
await autoApprove(session, event);
|
||||||
if (event.type === "timeline" && event.item.type === "assistant_message") {
|
if (event.type === "timeline" && event.item.type === "assistant_message") {
|
||||||
storedResponse = event.item.text;
|
storedResponse = event.item.text;
|
||||||
|
}
|
||||||
|
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
expect(storedResponse.toLowerCase()).toContain("stored");
|
||||||
break;
|
expect(existsSync(secretFile)).toBe(true);
|
||||||
|
|
||||||
|
await session.close();
|
||||||
|
|
||||||
|
const handle = session.describePersistence();
|
||||||
|
expect(handle).toBeTruthy();
|
||||||
|
expect(handle!.sessionId).toBeTruthy();
|
||||||
|
|
||||||
|
// Wait for history file to be written
|
||||||
|
const historyPaths = getClaudeHistoryPaths(cwd, handle!.sessionId);
|
||||||
|
expect(await waitForHistoryFile(historyPaths)).toBe(true);
|
||||||
|
expect(await waitForHistoryContains(historyPaths, secretWord)).toBe(true);
|
||||||
|
|
||||||
|
// Resume and verify context is preserved
|
||||||
|
resumed = await client.resumeSession(handle!, { cwd });
|
||||||
|
|
||||||
|
// Verify history is emitted on resume
|
||||||
|
const historyEvents: AgentStreamEvent[] = [];
|
||||||
|
for await (const event of resumed.streamHistory()) {
|
||||||
|
historyEvents.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Should have timeline events from previous session
|
||||||
|
const timelineEvents = historyEvents.filter((e) => e.type === "timeline");
|
||||||
|
expect(timelineEvents.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should include the user message with the secret word
|
||||||
|
const userMessages = timelineEvents.filter(
|
||||||
|
(e) => e.type === "timeline" && e.item.type === "user_message"
|
||||||
|
);
|
||||||
|
expect(userMessages.length).toBeGreaterThan(0);
|
||||||
|
const hasSecretWord = userMessages.some(
|
||||||
|
(e) =>
|
||||||
|
e.type === "timeline" &&
|
||||||
|
e.item.type === "user_message" &&
|
||||||
|
e.item.text.includes(secretWord)
|
||||||
|
);
|
||||||
|
expect(hasSecretWord).toBe(true);
|
||||||
|
|
||||||
|
// Ask the agent to recall what it wrote - this verifies context is actually preserved
|
||||||
|
const resumedResult = await resumed.run(
|
||||||
|
"What word did you write to secret.txt? Reply with only that exact word."
|
||||||
|
);
|
||||||
|
// The model should recall some part of the unique word we stored
|
||||||
|
// (models sometimes truncate or modify, so we check for any part of our unique token)
|
||||||
|
const recalledSomething =
|
||||||
|
resumedResult.finalText.includes(String(timestamp)) ||
|
||||||
|
resumedResult.finalText.includes("XYZZY") ||
|
||||||
|
resumedResult.finalText.includes("PLUGH");
|
||||||
|
expect(recalledSomething).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await resumed?.close();
|
||||||
|
await closeSessionAndCleanup(session, cwd);
|
||||||
}
|
}
|
||||||
expect(storedResponse.toLowerCase()).toContain("stored");
|
|
||||||
expect(existsSync(secretFile)).toBe(true);
|
|
||||||
|
|
||||||
await session.close();
|
|
||||||
|
|
||||||
const handle = session.describePersistence();
|
|
||||||
expect(handle).toBeTruthy();
|
|
||||||
expect(handle!.sessionId).toBeTruthy();
|
|
||||||
|
|
||||||
// Wait for history file to be written
|
|
||||||
const historyPaths = getClaudeHistoryPaths(cwd, handle!.sessionId);
|
|
||||||
expect(await waitForHistoryFile(historyPaths)).toBe(true);
|
|
||||||
expect(await waitForHistoryContains(historyPaths, secretWord)).toBe(true);
|
|
||||||
|
|
||||||
// Resume and verify context is preserved
|
|
||||||
const resumed = await client.resumeSession(handle!, { cwd });
|
|
||||||
|
|
||||||
// Verify history is emitted on resume
|
|
||||||
const historyEvents: AgentStreamEvent[] = [];
|
|
||||||
for await (const event of resumed.streamHistory()) {
|
|
||||||
historyEvents.push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have timeline events from previous session
|
|
||||||
const timelineEvents = historyEvents.filter((e) => e.type === "timeline");
|
|
||||||
expect(timelineEvents.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Should include the user message with the secret word
|
|
||||||
const userMessages = timelineEvents.filter(
|
|
||||||
(e) => e.type === "timeline" && e.item.type === "user_message"
|
|
||||||
);
|
|
||||||
expect(userMessages.length).toBeGreaterThan(0);
|
|
||||||
const hasSecretWord = userMessages.some(
|
|
||||||
(e) =>
|
|
||||||
e.type === "timeline" &&
|
|
||||||
e.item.type === "user_message" &&
|
|
||||||
e.item.text.includes(secretWord)
|
|
||||||
);
|
|
||||||
expect(hasSecretWord).toBe(true);
|
|
||||||
|
|
||||||
// Ask the agent to recall what it wrote - this verifies context is actually preserved
|
|
||||||
const resumedResult = await resumed.run(
|
|
||||||
"What word did you write to secret.txt? Reply with only that exact word."
|
|
||||||
);
|
|
||||||
// The model should recall some part of the unique word we stored
|
|
||||||
// (models sometimes truncate or modify, so we check for any part of our unique token)
|
|
||||||
const recalledSomething =
|
|
||||||
resumedResult.finalText.includes(String(timestamp)) ||
|
|
||||||
resumedResult.finalText.includes("XYZZY") ||
|
|
||||||
resumedResult.finalText.includes("PLUGH");
|
|
||||||
expect(recalledSomething).toBe(true);
|
|
||||||
|
|
||||||
await resumed.close();
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
},
|
},
|
||||||
180_000
|
180_000
|
||||||
);
|
);
|
||||||
@@ -936,19 +952,20 @@ 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);
|
||||||
|
|
||||||
const modes = await session.getAvailableModes();
|
try {
|
||||||
expect(modes.map((m) => m.id)).toContain("plan");
|
const modes = await session.getAvailableModes();
|
||||||
|
expect(modes.map((m) => m.id)).toContain("plan");
|
||||||
|
|
||||||
await session.setMode("plan");
|
await session.setMode("plan");
|
||||||
expect(await session.getCurrentMode()).toBe("plan");
|
expect(await session.getCurrentMode()).toBe("plan");
|
||||||
|
|
||||||
const result = await session.run(
|
const result = await session.run(
|
||||||
"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,42 +977,44 @@ 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);
|
||||||
await session.setMode("plan");
|
|
||||||
|
|
||||||
const events = session.stream(
|
try {
|
||||||
"Devise a plan to create a file named dummy.txt containing the word plan-test. After planning, proceed to execute your plan."
|
await session.setMode("plan");
|
||||||
);
|
|
||||||
|
|
||||||
let capturedPlan: string | null = null;
|
const events = session.stream(
|
||||||
for await (const event of events) {
|
"Devise a plan to create a file named dummy.txt containing the word plan-test. After planning, proceed to execute your plan."
|
||||||
await autoApprove(session, event);
|
);
|
||||||
if (event.type === "permission_requested" && event.request.kind === "plan") {
|
|
||||||
const planFromMetadata =
|
let capturedPlan: string | null = null;
|
||||||
typeof event.request.metadata?.planText === "string"
|
for await (const event of events) {
|
||||||
? event.request.metadata.planText
|
await autoApprove(session, event);
|
||||||
: null;
|
if (event.type === "permission_requested" && event.request.kind === "plan") {
|
||||||
const planFromInput =
|
const planFromMetadata =
|
||||||
typeof (event.request.input as any)?.plan === "string"
|
typeof event.request.metadata?.planText === "string"
|
||||||
? ((event.request.input as any)?.plan as string)
|
? event.request.metadata.planText
|
||||||
: null;
|
: null;
|
||||||
capturedPlan = planFromMetadata ?? planFromInput ?? capturedPlan;
|
const planFromInput =
|
||||||
|
typeof (event.request.input as any)?.plan === "string"
|
||||||
|
? ((event.request.input as any)?.plan as string)
|
||||||
|
: null;
|
||||||
|
capturedPlan = planFromMetadata ?? planFromInput ?? capturedPlan;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
expect(capturedPlan).not.toBeNull();
|
||||||
break;
|
expect(capturedPlan?.includes("dummy.txt")).toBe(true);
|
||||||
}
|
expect(await session.getCurrentMode()).toBe("acceptEdits");
|
||||||
|
|
||||||
|
const filePath = path.join(cwd, "dummy.txt");
|
||||||
|
expect(existsSync(filePath)).toBe(true);
|
||||||
|
expect(readFileSync(filePath, "utf8")).toContain("plan-test");
|
||||||
|
} finally {
|
||||||
|
await closeSessionAndCleanup(session, cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(capturedPlan).not.toBeNull();
|
|
||||||
expect(capturedPlan?.includes("dummy.txt")).toBe(true);
|
|
||||||
expect(await session.getCurrentMode()).toBe("acceptEdits");
|
|
||||||
|
|
||||||
const filePath = path.join(cwd, "dummy.txt");
|
|
||||||
expect(existsSync(filePath)).toBe(true);
|
|
||||||
expect(readFileSync(filePath, "utf8")).toContain("plan-test");
|
|
||||||
|
|
||||||
await session.close();
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
},
|
},
|
||||||
180_000
|
180_000
|
||||||
);
|
);
|
||||||
@@ -1008,67 +1027,68 @@ 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);
|
||||||
|
|
||||||
const prompt = [
|
try {
|
||||||
"You must call the AskUserQuestion tool exactly once and wait for the user's answer.",
|
const prompt = [
|
||||||
"Create one question with header 'color', prompt 'Choose a color', and options Blue and Red.",
|
"You must call the AskUserQuestion tool exactly once and wait for the user's answer.",
|
||||||
"Set multiSelect to false.",
|
"Create one question with header 'color', prompt 'Choose a color', and options Blue and Red.",
|
||||||
"After receiving the answer, reply with exactly QUESTION_FLOW_DONE.",
|
"Set multiSelect to false.",
|
||||||
"Do not use any other tools.",
|
"After receiving the answer, reply with exactly QUESTION_FLOW_DONE.",
|
||||||
].join(" ");
|
"Do not use any other tools.",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
let capturedQuestion: AgentPermissionRequest | null = null;
|
let capturedQuestion: AgentPermissionRequest | null = null;
|
||||||
let sawResolvedAllow = false;
|
let sawResolvedAllow = false;
|
||||||
let sawDone = false;
|
let sawDone = false;
|
||||||
|
|
||||||
for await (const event of session.stream(prompt)) {
|
for await (const event of session.stream(prompt)) {
|
||||||
if (
|
if (
|
||||||
event.type === "permission_requested" &&
|
event.type === "permission_requested" &&
|
||||||
event.request.kind === "question" &&
|
event.request.kind === "question" &&
|
||||||
!capturedQuestion
|
!capturedQuestion
|
||||||
) {
|
) {
|
||||||
capturedQuestion = event.request;
|
capturedQuestion = event.request;
|
||||||
const baseInput =
|
const baseInput =
|
||||||
typeof capturedQuestion.input === "object" && capturedQuestion.input !== null
|
typeof capturedQuestion.input === "object" && capturedQuestion.input !== null
|
||||||
? (capturedQuestion.input as Record<string, unknown>)
|
? (capturedQuestion.input as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
await session.respondToPermission(capturedQuestion.id, {
|
await session.respondToPermission(capturedQuestion.id, {
|
||||||
behavior: "allow",
|
behavior: "allow",
|
||||||
updatedInput: {
|
updatedInput: {
|
||||||
...baseInput,
|
...baseInput,
|
||||||
answers: { color: "Blue" },
|
answers: { color: "Blue" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.type === "permission_resolved" &&
|
||||||
|
capturedQuestion &&
|
||||||
|
event.requestId === capturedQuestion.id &&
|
||||||
|
event.resolution.behavior === "allow"
|
||||||
|
) {
|
||||||
|
sawResolvedAllow = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.type === "timeline" &&
|
||||||
|
event.item.type === "assistant_message" &&
|
||||||
|
event.item.text.includes("QUESTION_FLOW_DONE")
|
||||||
|
) {
|
||||||
|
sawDone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
expect(capturedQuestion).not.toBeNull();
|
||||||
event.type === "permission_resolved" &&
|
expect(sawResolvedAllow).toBe(true);
|
||||||
capturedQuestion &&
|
expect(session.getPendingPermissions()).toHaveLength(0);
|
||||||
event.requestId === capturedQuestion.id &&
|
expect(sawDone).toBe(true);
|
||||||
event.resolution.behavior === "allow"
|
} finally {
|
||||||
) {
|
await closeSessionAndCleanup(session, cwd);
|
||||||
sawResolvedAllow = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.type === "timeline" &&
|
|
||||||
event.item.type === "assistant_message" &&
|
|
||||||
event.item.text.includes("QUESTION_FLOW_DONE")
|
|
||||||
) {
|
|
||||||
sawDone = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(capturedQuestion).not.toBeNull();
|
|
||||||
expect(sawResolvedAllow).toBe(true);
|
|
||||||
expect(session.getPendingPermissions()).toHaveLength(0);
|
|
||||||
expect(sawDone).toBe(true);
|
|
||||||
|
|
||||||
await session.close();
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
},
|
},
|
||||||
180_000
|
180_000
|
||||||
);
|
);
|
||||||
@@ -1271,50 +1291,51 @@ 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);
|
||||||
|
|
||||||
const events = session.stream(
|
try {
|
||||||
"Use the Task tool to launch a sub-agent that reads the current directory listing. " +
|
const events = session.stream(
|
||||||
"The sub-agent should run 'ls' in the shell and report the result. " +
|
"Use the Task tool to launch a sub-agent that reads the current directory listing. " +
|
||||||
"Do NOT do the work yourself — delegate it to a sub-agent via the Task tool."
|
"The sub-agent should run 'ls' in the shell and report the result. " +
|
||||||
);
|
"Do NOT do the work yourself — delegate it to a sub-agent via the Task tool."
|
||||||
|
);
|
||||||
|
|
||||||
const timeline: AgentTimelineItem[] = [];
|
const timeline: AgentTimelineItem[] = [];
|
||||||
|
|
||||||
for await (const event of events) {
|
for await (const event of events) {
|
||||||
await autoApprove(session, event);
|
await autoApprove(session, event);
|
||||||
if (event.type === "timeline") {
|
if (event.type === "timeline") {
|
||||||
timeline.push(event.item);
|
timeline.push(event.item);
|
||||||
|
}
|
||||||
|
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
|
||||||
break;
|
const toolCalls = timeline.filter(
|
||||||
|
(item): item is ToolCallItem => item.type === "tool_call"
|
||||||
|
);
|
||||||
|
|
||||||
|
// There should be at least one Task tool call
|
||||||
|
const taskCalls = toolCalls.filter((item) => item.name === "Task");
|
||||||
|
expect(taskCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// We can't 100% guarantee Claude won't also use tools directly, but Task detail
|
||||||
|
// updates should exist for the sub-agent activity
|
||||||
|
const taskWithSubAgentDetail = taskCalls.filter(
|
||||||
|
(item) => item.detail.type === "sub_agent"
|
||||||
|
);
|
||||||
|
if (taskCalls.length > 0) {
|
||||||
|
expect(taskWithSubAgentDetail.length).toBeGreaterThanOrEqual(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the curator produces clean output with collapsed Task entries
|
||||||
|
const curated = curateAgentActivity(timeline);
|
||||||
|
const lines = curated.split("\n");
|
||||||
|
const taskLines = lines.filter((l) => l.includes("[Task]"));
|
||||||
|
// Each Task callId should appear at most once in curated output
|
||||||
|
expect(taskLines.length).toBeLessThanOrEqual(taskCalls.length);
|
||||||
|
} finally {
|
||||||
|
await closeSessionAndCleanup(session, cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolCalls = timeline.filter(
|
|
||||||
(item): item is ToolCallItem => item.type === "tool_call"
|
|
||||||
);
|
|
||||||
|
|
||||||
// There should be at least one Task tool call
|
|
||||||
const taskCalls = toolCalls.filter((item) => item.name === "Task");
|
|
||||||
expect(taskCalls.length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
// We can't 100% guarantee Claude won't also use tools directly, but Task detail
|
|
||||||
// updates should exist for the sub-agent activity
|
|
||||||
const taskWithSubAgentDetail = taskCalls.filter(
|
|
||||||
(item) => item.detail.type === "sub_agent"
|
|
||||||
);
|
|
||||||
if (taskCalls.length > 0) {
|
|
||||||
expect(taskWithSubAgentDetail.length).toBeGreaterThanOrEqual(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the curator produces clean output with collapsed Task entries
|
|
||||||
const curated = curateAgentActivity(timeline);
|
|
||||||
const lines = curated.split("\n");
|
|
||||||
const taskLines = lines.filter((l) => l.includes("[Task]"));
|
|
||||||
// Each Task callId should appear at most once in curated output
|
|
||||||
expect(taskLines.length).toBeLessThanOrEqual(taskCalls.length);
|
|
||||||
|
|
||||||
await session.close();
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
},
|
},
|
||||||
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,
|
||||||
id: model.value,
|
label: "Claude model discovery",
|
||||||
label: normalizeClaudeModelLabel(model),
|
});
|
||||||
description: model.description,
|
if (models.length === 0) {
|
||||||
thinkingOptions: [
|
this.logger.warn(
|
||||||
{ id: "off", label: "Off", isDefault: true },
|
"Claude SDK returned an empty model catalog; using fallback Claude model aliases"
|
||||||
{ id: "on", label: "On" },
|
);
|
||||||
],
|
return buildClaudeFallbackModels();
|
||||||
defaultThinkingOptionId: "off",
|
}
|
||||||
metadata: {
|
return models.map((model) =>
|
||||||
|
toClaudeModelDefinition({
|
||||||
|
id: model.value,
|
||||||
|
label: normalizeClaudeModelLabel(model),
|
||||||
description: model.description,
|
description: model.description,
|
||||||
},
|
})
|
||||||
}));
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: error },
|
||||||
|
"Failed to fetch Claude model catalog from SDK; using fallback Claude model aliases"
|
||||||
|
);
|
||||||
|
return buildClaudeFallbackModels();
|
||||||
} finally {
|
} 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,33 +878,46 @@ 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);
|
||||||
"Run the exact shell command `printf \"ok\" > shell.txt`. After it completes, reply SHELL_DONE."
|
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;
|
let failure: string | null = null;
|
||||||
for await (const event of shellEvents) {
|
try {
|
||||||
if (event.type === "permission_requested") {
|
const shellEvents = shellSession.stream(
|
||||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
"Run the exact shell command `printf \"ok\" > shell.txt`. After it completes, reply SHELL_DONE."
|
||||||
}
|
);
|
||||||
if (event.type === "timeline") {
|
for await (const event of shellEvents) {
|
||||||
timelineItems.push(event.item);
|
if (event.type === "permission_requested") {
|
||||||
if (event.item.type === "assistant_message") {
|
await shellSession.respondToPermission(event.request.id, { behavior: "allow" });
|
||||||
sawAssistantMessage = true;
|
|
||||||
}
|
}
|
||||||
if (hasShellCommand(event.item, "printf")) {
|
if (event.type === "timeline") {
|
||||||
sawShellTool = true;
|
timelineItems.push(event.item);
|
||||||
if (event.item.status === "completed") {
|
if (event.item.type === "assistant_message") {
|
||||||
sawShellCompleted = true;
|
sawAssistantMessage = true;
|
||||||
|
}
|
||||||
|
if (hasShellCommand(event.item, "printf")) {
|
||||||
|
sawShellTool = true;
|
||||||
|
if (event.item.status === "completed") {
|
||||||
|
sawShellCompleted = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (event.type === "turn_failed") {
|
||||||
|
failure = event.error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (event.type === "turn_completed") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (event.type === "turn_failed") {
|
} finally {
|
||||||
failure = event.error;
|
await shellSession.close();
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (event.type === "turn_completed") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failure) {
|
if (failure) {
|
||||||
@@ -852,39 +930,50 @@ 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);
|
||||||
buildStrictApplyPatchPrompt(patch, "PATCH_DONE", {
|
const patchSession = await patchClient.createSession({
|
||||||
includePermissionStep: true,
|
provider: "codex",
|
||||||
})
|
cwd,
|
||||||
);
|
modeId: "full-access",
|
||||||
|
approvalPolicy: "on-request",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const patchEvents = patchSession.stream(
|
||||||
|
buildStrictApplyPatchPrompt(patch, "PATCH_DONE", {
|
||||||
|
includePermissionStep: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
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") {
|
|
||||||
timelineItems.push(event.item);
|
|
||||||
if (event.item.type === "assistant_message") {
|
|
||||||
sawAssistantMessage = true;
|
|
||||||
}
|
}
|
||||||
if (hasApplyPatchFile(event.item, "patch.txt")) {
|
if (event.type === "timeline") {
|
||||||
sawPatchTool = true;
|
timelineItems.push(event.item);
|
||||||
if (event.item.status === "completed") {
|
if (event.item.type === "assistant_message") {
|
||||||
sawPatchCompleted = true;
|
sawAssistantMessage = true;
|
||||||
|
}
|
||||||
|
if (hasApplyPatchFile(event.item, "patch.txt")) {
|
||||||
|
sawPatchTool = true;
|
||||||
|
if (event.item.status === "completed") {
|
||||||
|
sawPatchCompleted = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (event.type === "turn_failed") {
|
||||||
|
failure = event.error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (event.type === "turn_completed") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (event.type === "turn_failed") {
|
} finally {
|
||||||
failure = event.error;
|
await patchSession.close();
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (event.type === "turn_completed") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await session.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
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
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 {
|
try {
|
||||||
daemon = await createPaseoDaemon(config, logger);
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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*\(/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
...(reason ? { reason } : {}),
|
requestId,
|
||||||
})
|
...(reason ? { reason } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleShutdownServerRequest(requestId: string): Promise<void> {
|
||||||
|
this.sessionLogger.warn('Shutdown requested via websocket')
|
||||||
|
this.emit({
|
||||||
|
type: 'status',
|
||||||
|
payload: {
|
||||||
|
status: 'shutdown_requested',
|
||||||
|
clientId: this.clientId,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.emitLifecycleIntent({
|
||||||
|
type: 'shutdown',
|
||||||
|
clientId: this.clientId,
|
||||||
|
requestId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitLifecycleIntent(intent: SessionLifecycleIntent): void {
|
||||||
|
if (!this.onLifecycleIntent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
setTimeout(() => {
|
this.onLifecycleIntent(intent)
|
||||||
process.exit(0)
|
} catch (error) {
|
||||||
}, RESTART_EXIT_DELAY_MS)
|
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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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/**"],
|
||||||
|
|||||||
Reference in New Issue
Block a user