mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94f7701155 | ||
|
|
ba68e2599b | ||
|
|
7631505a79 | ||
|
|
ed308bc64c | ||
|
|
a72d5214ce | ||
|
|
dea127bfe5 | ||
|
|
1f21974a76 | ||
|
|
ef9140df01 | ||
|
|
27f5896ebe | ||
|
|
3bd193b5ba | ||
|
|
7dd6ddef53 | ||
|
|
6197336f60 | ||
|
|
d97a5ec036 | ||
|
|
13adfee3a4 | ||
|
|
5387a16bb6 | ||
|
|
6d7848059e | ||
|
|
a324ac5d71 | ||
|
|
6f04d33b18 | ||
|
|
1526532ff4 | ||
|
|
dbff649d88 | ||
|
|
a6ad4ff430 | ||
|
|
165fc11955 | ||
|
|
426b81e5d2 | ||
|
|
2c90498cb8 | ||
|
|
c42d48a8ff | ||
|
|
54b1f524b7 | ||
|
|
85db9711b3 | ||
|
|
3c2a98b09b | ||
|
|
9e3ebd9665 | ||
|
|
a1606ea515 | ||
|
|
fa8fab75aa | ||
|
|
a3f350a7dc | ||
|
|
6a8f507d10 | ||
|
|
ea0bd47c1d | ||
|
|
f4dcb3954a | ||
|
|
7fc4dd1e86 | ||
|
|
78bb56d90d | ||
|
|
a788064e32 | ||
|
|
74f0b9da56 | ||
|
|
aa01f23ab3 | ||
|
|
ae3f1bb5df | ||
|
|
2064bac918 | ||
|
|
fa4294a0e1 | ||
|
|
c993858898 | ||
|
|
6e04153b95 | ||
|
|
a6f0d79212 | ||
|
|
4b2e0357aa | ||
|
|
acf88f4aca | ||
|
|
412c66ce4b | ||
|
|
cb906d4223 | ||
|
|
bf0d3b9786 | ||
|
|
7f37255f88 | ||
|
|
5c03e25232 | ||
|
|
b440f44111 | ||
|
|
d433e1a362 |
@@ -1,18 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.69 - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Paseo now recovers automatically when an internal daemon process crashes — your agents stay connected instead of getting stuck and you don't have to restart anything.
|
||||
- Answering an interactive question from a Claude agent now reaches Claude correctly instead of being dropped. ([#760](https://github.com/getpaseo/paseo/pull/760) by [@somus](https://github.com/somus))
|
||||
|
||||
## 0.1.68 - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- The desktop app no longer fails on first launch after a fresh install.
|
||||
|
||||
## 0.1.67 - 2026-05-03
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# Ad-hoc daemon testing
|
||||
|
||||
Spin up an isolated in-process daemon test harness without touching the main daemon on port 6767.
|
||||
|
||||
This is for test code only. Executable daemon processes must start through
|
||||
`scripts/supervisor-entrypoint.ts` or `dist/scripts/supervisor-entrypoint.js`;
|
||||
do not use `createPaseoDaemon` as a product launch path.
|
||||
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -89,7 +85,7 @@ await client.close();
|
||||
await daemon.close(); // stops daemon + cleans up temp dirs
|
||||
```
|
||||
|
||||
The test helper does **not** expose `providerOverrides`. In test harnesses, use `createPaseoDaemon` directly when you need it (see quick start above).
|
||||
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
|
||||
|
||||
## Common client methods
|
||||
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-rjzmIH918poK05edU/f6G3iiP16Ra7g7vM0k5HbfKRs=";
|
||||
npmDepsHash = "sha256-14DjNXerk2vLOe7UV0oVo+ifmskwsWpu/jahxrLw4M8=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
@@ -123,7 +123,7 @@ buildNpmPackage rec {
|
||||
# Create wrapper for the server entry point (for systemd / direct use)
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||
--add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \
|
||||
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
|
||||
--set NODE_ENV production
|
||||
|
||||
# Create wrapper for the CLI
|
||||
|
||||
Generated
+13
-13
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -38548,7 +38548,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -38674,10 +38674,10 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.69",
|
||||
"@getpaseo/server": "0.1.67",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -38719,7 +38719,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38768,7 +38768,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -38804,7 +38804,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -38830,7 +38830,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38845,12 +38845,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.69",
|
||||
"@getpaseo/relay": "0.1.69",
|
||||
"@getpaseo/highlight": "0.1.67",
|
||||
"@getpaseo/relay": "0.1.67",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
@@ -39303,7 +39303,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
// "00-" prefix is intentional: this file must sort before every other spec.
|
||||
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
|
||||
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
connectArchiveTabDaemonClient,
|
||||
expectSessionsEmptyState,
|
||||
openSessions,
|
||||
} from "./helpers/archive-tab";
|
||||
|
||||
test.describe("Sessions screen empty state", () => {
|
||||
test.beforeAll(async () => {
|
||||
const client = await connectArchiveTabDaemonClient();
|
||||
try {
|
||||
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
|
||||
if (history.entries.length > 0) {
|
||||
throw new Error(
|
||||
`Sessions empty-state precondition failed: daemon already has ${history.entries.length} agent(s). ` +
|
||||
`Either a spec that sorts before 00-sessions-empty.spec.ts created agents, ` +
|
||||
`or the daemon has stale history from a previous run.`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("shows empty placeholder when there is no session history", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "sessions-empty-" });
|
||||
await workspace.navigateTo();
|
||||
await openSessions(page);
|
||||
await expectSessionsEmptyState(page);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
awaitAssistantMessage,
|
||||
expectAgentIdle,
|
||||
expectInlineWorkingIndicator,
|
||||
expectTurnCopyButton,
|
||||
expectScrollFollowsNewContent,
|
||||
} from "./helpers/agent-stream";
|
||||
import { startRunningMockAgent } from "./helpers/composer";
|
||||
|
||||
test.describe("Agent stream UI", () => {
|
||||
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "stream-scroll-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stream for auto-scroll test.",
|
||||
});
|
||||
try {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectScrollFollowsNewContent(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "stream-indicator-",
|
||||
model: "ten-second-stream",
|
||||
prompt: "Stream briefly for indicator transition test.",
|
||||
});
|
||||
try {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await expectAgentIdle(page, 30_000);
|
||||
await expectTurnCopyButton(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,284 +0,0 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { clickNewChat } from "./helpers/launcher";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { expectAgentIdle } from "./helpers/agent-stream";
|
||||
import {
|
||||
openAttachmentMenu,
|
||||
openGithubPickerFromMenu,
|
||||
attachImageFromMenu,
|
||||
expectAttachmentPill,
|
||||
removeAttachmentPill,
|
||||
openImageLightbox,
|
||||
closeImageLightbox,
|
||||
pressInterruptShortcut,
|
||||
expectComposerDraft,
|
||||
expectComposerDisabled,
|
||||
expectComposerEditable,
|
||||
expectAttachButtonDisabled,
|
||||
fillComposerDraft,
|
||||
sendDraftToQueue,
|
||||
expectQueuedMessageButton,
|
||||
startRunningMockAgent,
|
||||
selectGithubOption,
|
||||
expectGithubAttachmentPill,
|
||||
openGithubWorkspace,
|
||||
} from "./helpers/composer";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
delayBrowserAgentCreatedStatus,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
|
||||
|
||||
const MINIMAL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
|
||||
|
||||
test.describe("Composer attachments", () => {
|
||||
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-plus-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await openAttachmentMenu(page);
|
||||
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
|
||||
});
|
||||
|
||||
test("GitHub combobox does not render until the picker is opened", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-gh-lazy-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible();
|
||||
|
||||
await openGithubPickerFromMenu(page);
|
||||
|
||||
await expect(page.getByPlaceholder("Search issues and PRs...")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
page.getByTestId("combobox-empty-text").or(page.getByText("Searching...")),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
prefix: "paseo-e2e-attach-issue-",
|
||||
issues: [{ title: "fix: attach-issue-unique-alpha" }],
|
||||
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(
|
||||
page,
|
||||
"attach-issue-unique-alpha",
|
||||
`issue:${ghRepo.issues[0].number}`,
|
||||
);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.issues[0].number,
|
||||
title: ghRepo.issues[0].title,
|
||||
});
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
prefix: "paseo-e2e-attach-pr-",
|
||||
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.prs[0].number,
|
||||
title: ghRepo.prs[0].title,
|
||||
});
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.fixme("workspace-review pill suppresses on X-click and reappears after send", async () => {
|
||||
// The review attachment is created via InlineReviewEditor in surface.tsx (addComment action).
|
||||
// Automating this requires: a workspace with staged changes, navigating to the diff panel,
|
||||
// hovering the gutter "+" button, typing a comment, and submitting. A dedicated
|
||||
// helpers/review.ts with addInlineReviewComment(page, filePath, lineNumber, comment) is
|
||||
// needed before this can be exercised end-to-end.
|
||||
});
|
||||
|
||||
test.fixme("browser-element attachment pill is created from Electron webview selection", async () => {
|
||||
// The browser-element attachment is only created in browser-pane.electron.tsx via DOM
|
||||
// element selection in the Electron webview. It is not exercisable in headless Chromium E2E.
|
||||
});
|
||||
|
||||
test("image lightbox opens on pill click and closes on Escape", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-lightbox-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await openImageLightbox(page);
|
||||
await closeImageLightbox(page);
|
||||
});
|
||||
|
||||
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-pill-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
});
|
||||
|
||||
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-remove-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
|
||||
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("submitting while agent is running queues the message and clears the draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "attach-queue-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stay running for queue test.",
|
||||
});
|
||||
try {
|
||||
await fillComposerDraft(page, "queued draft text");
|
||||
await sendDraftToQueue(page);
|
||||
|
||||
await expectQueuedMessageButton(page);
|
||||
await expectComposerDraft(page, "");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Escape interrupt cancels the running agent and preserves composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "attach-interrupt-",
|
||||
model: "ten-second-stream",
|
||||
prompt: "Stay running for interrupt test.",
|
||||
});
|
||||
try {
|
||||
await fillComposerDraft(page, "preserve me");
|
||||
await pressInterruptShortcut(page);
|
||||
|
||||
await expectAgentIdle(page, 15_000);
|
||||
await expectComposerDraft(page, "preserve me");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
|
||||
|
||||
const repo = await createTempGitRepo("attach-lock-");
|
||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||
const daemonClient = await connectNewWorkspaceDaemonClient();
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(daemonClient, repo.path);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await fillComposerDraft(page, "lock test prompt");
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
|
||||
await agentCreatedDelay.waitForCreateRequest();
|
||||
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||
|
||||
await expectComposerDisabled(page);
|
||||
await expectAttachButtonDisabled(page);
|
||||
|
||||
agentCreatedDelay.release();
|
||||
|
||||
await expectComposerEditable(page);
|
||||
} finally {
|
||||
agentCreatedDelay.release();
|
||||
await daemonClient.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -215,28 +215,8 @@ async function createFakeGhBin(): Promise<string> {
|
||||
await writeFile(
|
||||
ghPath,
|
||||
`#!/usr/bin/env node
|
||||
const { spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function findRealGh() {
|
||||
const fakeBinDir = __dirname;
|
||||
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
||||
if (dir === fakeBinDir) continue;
|
||||
const candidate = path.join(dir, "gh");
|
||||
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function forwardToRealGh() {
|
||||
const realGh = findRealGh();
|
||||
if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); }
|
||||
const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env });
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
if (args[0] === "auth" && args[1] === "status") {
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -258,21 +238,8 @@ if (args[0] === "pr" && args[1] === "list") {
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-pr.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
}
|
||||
|
||||
if (args[0] === "api" && args[1] === "graphql") {
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-timeline.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
console.error("no pull requests found for branch");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args[0] === "issue" && args[1] === "list") {
|
||||
@@ -280,7 +247,8 @@ if (args[0] === "issue" && args[1] === "list") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
forwardToRealGh();
|
||||
console.error("Unsupported fake gh invocation: " + args.join(" "));
|
||||
process.exit(1);
|
||||
`,
|
||||
);
|
||||
await chmod(ghPath, 0o755);
|
||||
@@ -605,7 +573,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const { openAiUsable, localModelsDir } = args.dictation;
|
||||
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
const child = spawn(tsxBin, ["src/server/index.ts"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { readScrollMetrics, waitForContentGrowth, expectNearBottom } from "./agent-bottom-anchor";
|
||||
|
||||
export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise<void> {
|
||||
const messages = page.getByTestId("assistant-message");
|
||||
@@ -16,20 +15,3 @@ export async function awaitToolCall(page: Page, toolName: string | RegExp): Prom
|
||||
export async function expectAgentIdle(page: Page, timeout = 30_000): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout });
|
||||
}
|
||||
|
||||
// The working indicator is an animated spinner View — no semantic ARIA role, testId is correct.
|
||||
export async function expectInlineWorkingIndicator(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectTurnCopyButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Copy turn" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectScrollFollowsNewContent(page: Page): Promise<void> {
|
||||
const { contentHeight } = await readScrollMetrics(page);
|
||||
await waitForContentGrowth(page, contentHeight);
|
||||
await expectNearBottom(page);
|
||||
}
|
||||
|
||||
@@ -36,9 +36,6 @@ interface ArchiveTabDaemonClient {
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
}
|
||||
|
||||
function getDaemonPort(): string {
|
||||
@@ -268,10 +265,8 @@ export async function openSessions(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const AGENT_ROW_SELECTOR = '[data-testid^="agent-row-"]';
|
||||
|
||||
function getSessionRowByTitle(page: Page, title: string) {
|
||||
return page.locator(AGENT_ROW_SELECTOR).filter({ hasText: title }).first();
|
||||
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
|
||||
}
|
||||
|
||||
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
|
||||
@@ -288,12 +283,6 @@ export async function clickSessionRow(page: Page, title: string): Promise<void>
|
||||
await row.click();
|
||||
}
|
||||
|
||||
export async function expectSessionsEmptyState(page: Page): Promise<void> {
|
||||
// Guard: if session rows appear, a prior spec polluted the shared daemon — see 00-sessions-empty.spec.ts.
|
||||
await expect(page.locator(AGENT_ROW_SELECTOR)).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(page.getByText("No sessions yet")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function archiveAgentFromSessions(
|
||||
page: Page,
|
||||
input: { agentId: string; title: string },
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
|
||||
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
|
||||
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||
import { waitForTabBar } from "./launcher";
|
||||
|
||||
function composerInput(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
@@ -21,20 +15,6 @@ export async function expectComposerVisible(
|
||||
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerDisabled(page: Page): Promise<void> {
|
||||
// React Native TextInput with editable={false} renders as <textarea readonly> on web,
|
||||
// not <textarea disabled>. Use not.toBeEditable() to match either form.
|
||||
await expect(composerInput(page)).not.toBeEditable({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerDraft(page: Page, text: string): Promise<void> {
|
||||
await expect(composerInput(page)).toHaveValue(text, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerEditable(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
const input = composerInput(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
@@ -42,154 +22,8 @@ export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
|
||||
await composerInput(page).fill(text);
|
||||
}
|
||||
|
||||
export async function sendDraftToQueue(page: Page): Promise<void> {
|
||||
await composerInput(page).press("Control+Enter");
|
||||
}
|
||||
|
||||
export async function expectQueuedMessageButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelAgent(page: Page): Promise<void> {
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||
await expect(stopButton).toBeVisible({ timeout: 10_000 });
|
||||
await stopButton.click();
|
||||
}
|
||||
|
||||
/** Escape is bound to the "agent.interrupt" keyboard shortcut. */
|
||||
export async function pressInterruptShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function openAttachmentMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click();
|
||||
await expect(page.getByTestId("message-input-attachment-menu")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectAttachButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("message-input-attach-button").filter({ visible: true }).first(),
|
||||
).toBeDisabled({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function attachImageFromMenu(
|
||||
page: Page,
|
||||
file: { name: string; mimeType: string; buffer: Buffer },
|
||||
): Promise<void> {
|
||||
const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 });
|
||||
await openAttachmentMenu(page);
|
||||
await page.getByTestId("message-input-attachment-menu-item-image").click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles([file]);
|
||||
}
|
||||
|
||||
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
|
||||
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
|
||||
export async function removeAttachmentPill(
|
||||
page: Page,
|
||||
pillTestId: string,
|
||||
removeAccessibilityLabel: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId(pillTestId).first().hover();
|
||||
await page.getByRole("button", { name: removeAccessibilityLabel }).first().click();
|
||||
}
|
||||
|
||||
export async function expectGithubAttachmentPill(
|
||||
page: Page,
|
||||
input: { number: number; title: string },
|
||||
): Promise<void> {
|
||||
const pill = page.getByTestId("composer-github-attachment-pill").first();
|
||||
await expect(pill).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pill).toContainText(`#${input.number}`);
|
||||
await expect(pill).toContainText(input.title);
|
||||
}
|
||||
|
||||
export async function openImageLightbox(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open image attachment" }).first().click();
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function closeImageLightbox(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
|
||||
await openAttachmentMenu(page);
|
||||
await page.getByTestId("message-input-attachment-menu-item-github").click();
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "pr:1"), and click it. */
|
||||
export async function selectGithubOption(
|
||||
page: Page,
|
||||
searchTerm: string,
|
||||
optionId: string,
|
||||
): Promise<void> {
|
||||
await openGithubPickerFromMenu(page);
|
||||
const searchInput = page.getByPlaceholder("Search issues and PRs...");
|
||||
await expect(searchInput).toBeVisible({ timeout: 5_000 });
|
||||
await searchInput.fill(searchTerm);
|
||||
const option = page.getByTestId(`composer-github-option-${optionId}`);
|
||||
await expect(option).toBeVisible({ timeout: 15_000 });
|
||||
await option.click();
|
||||
}
|
||||
|
||||
export interface MockAgentSetup {
|
||||
client: TerminalPerfDaemonClient;
|
||||
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
||||
}
|
||||
|
||||
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
|
||||
export async function startRunningMockAgent(
|
||||
page: Page,
|
||||
opts: { prefix: string; model: string; prompt: string },
|
||||
): Promise<MockAgentSetup> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
|
||||
|
||||
const repo = await createTempGitRepo(opts.prefix);
|
||||
const client = await connectTerminalClient();
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
model: opts.model,
|
||||
initialPrompt: opts.prompt,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
return { client, repo };
|
||||
}
|
||||
|
||||
export interface GithubWorkspaceHandle {
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Open a workspace backed by an existing repo path (e.g. a cloned GitHub repo). */
|
||||
export async function openGithubWorkspace(
|
||||
page: Page,
|
||||
repoPath: string,
|
||||
): Promise<GithubWorkspaceHandle> {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const opened = await client.openProject(repoPath);
|
||||
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
|
||||
await openHomeWithProject(page, repoPath);
|
||||
await selectWorkspaceInSidebar(page, opened.workspace.id);
|
||||
await waitForTabBar(page);
|
||||
return { cleanup: () => client.close().catch(() => undefined) };
|
||||
}
|
||||
|
||||
@@ -30,15 +30,10 @@ export function buildSeededHost(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export const TEST_MOCK_PROVIDER_PREFERENCES = {
|
||||
...TEST_PROVIDER_PREFERENCES,
|
||||
mock: { model: "ten-second-stream" },
|
||||
} as const;
|
||||
|
||||
export function buildCreateAgentPreferences(serverId: string) {
|
||||
return {
|
||||
serverId,
|
||||
provider: "mock" as const,
|
||||
providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES,
|
||||
provider: "codex" as const,
|
||||
providerPreferences: TEST_PROVIDER_PREFERENCES,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,13 +32,12 @@ export async function loadRealDaemonState(): Promise<RealDaemonState> {
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
|
||||
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
|
||||
const data: DaemonApiStatus = await resp.json();
|
||||
const data = (await resp.json()) as DaemonApiStatus;
|
||||
|
||||
let pid: number | null = null;
|
||||
try {
|
||||
const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8");
|
||||
const pidContent: PidFileContent = JSON.parse(raw);
|
||||
pid = pidContent.pid ?? null;
|
||||
pid = (JSON.parse(raw) as PidFileContent).pid ?? null;
|
||||
} catch (err) {
|
||||
// PID file may not be present yet on a very fresh daemon start
|
||||
console.warn("[desktop-updates] paseo.pid not found:", err);
|
||||
@@ -71,12 +70,6 @@ export interface ConfirmDialogCall {
|
||||
title: string | undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects window.paseoDesktop before app load so all Electron-gated code
|
||||
* activates. The update-check IPC is mocked at the boundary so the real
|
||||
@@ -158,14 +151,12 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}
|
||||
|
||||
if (command === "patch_desktop_settings") {
|
||||
const daemon = args?.daemon;
|
||||
if (
|
||||
daemon !== null &&
|
||||
typeof daemon === "object" &&
|
||||
"manageBuiltInDaemon" in daemon &&
|
||||
typeof daemon.manageBuiltInDaemon === "boolean"
|
||||
) {
|
||||
manageDaemon = daemon.manageBuiltInDaemon;
|
||||
const patchDaemon =
|
||||
args?.daemon && typeof args.daemon === "object"
|
||||
? (args.daemon as Record<string, unknown>)
|
||||
: {};
|
||||
if (typeof patchDaemon.manageBuiltInDaemon === "boolean") {
|
||||
manageDaemon = patchDaemon.manageBuiltInDaemon;
|
||||
}
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
@@ -192,9 +183,9 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
},
|
||||
dialog: {
|
||||
ask: async (message: string, options?: Record<string, unknown>) => {
|
||||
window.__capturedDialogCall = {
|
||||
(window as unknown as Record<string, unknown>).__capturedDialogCall = {
|
||||
message,
|
||||
title: typeof options?.title === "string" ? options.title : undefined,
|
||||
title: options?.title,
|
||||
};
|
||||
return cfg.confirmShouldAccept ?? false;
|
||||
},
|
||||
@@ -237,8 +228,13 @@ export async function interceptDaemonManagementConfirmDialog(
|
||||
page: Page,
|
||||
): Promise<ConfirmDialogCall> {
|
||||
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
|
||||
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 5_000 });
|
||||
return page.evaluate(() => window.__capturedDialogCall!);
|
||||
await page.waitForFunction(
|
||||
() => !!(window as unknown as Record<string, unknown>).__capturedDialogCall,
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
return page.evaluate(
|
||||
() => (window as unknown as Record<string, unknown>).__capturedDialogCall as ConfirmDialogCall,
|
||||
);
|
||||
}
|
||||
|
||||
export async function toggleDaemonManagement(
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export function hasGithubAuth(): boolean {
|
||||
try {
|
||||
execSync("gh auth status", { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CheckSpec {
|
||||
context: string;
|
||||
state: "success" | "failure" | "pending";
|
||||
}
|
||||
|
||||
export interface PrSpec {
|
||||
title: string;
|
||||
state: "open" | "merged" | "closed" | "draft";
|
||||
checks?: CheckSpec[];
|
||||
commentCount?: number;
|
||||
}
|
||||
|
||||
export interface IssueSpec {
|
||||
title: string;
|
||||
body?: string;
|
||||
labels?: string[];
|
||||
state?: "open" | "closed";
|
||||
}
|
||||
|
||||
export interface GhPrFixture {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
branch: string;
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
export interface GhIssueFixture {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GhRepoFixture {
|
||||
owner: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
prs: GhPrFixture[];
|
||||
issues: GhIssueFixture[];
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
function gh(args: string[], opts?: { cwd?: string }): string {
|
||||
return execFileSync("gh", args, {
|
||||
cwd: opts?.cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function git(args: string[], cwd: string): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
async function seedPr(args: {
|
||||
spec: PrSpec;
|
||||
branch: string;
|
||||
index: number;
|
||||
basePath: string;
|
||||
authedUrl: string;
|
||||
fullName: string;
|
||||
repoName: string;
|
||||
}): Promise<{ fixture: GhPrFixture; localPath: string }> {
|
||||
const { spec, branch, index, basePath, authedUrl, fullName, repoName } = args;
|
||||
|
||||
const createArgs = [
|
||||
"pr",
|
||||
"create",
|
||||
"--title",
|
||||
spec.title,
|
||||
"--base",
|
||||
"main",
|
||||
"--head",
|
||||
branch,
|
||||
"--body",
|
||||
"",
|
||||
];
|
||||
if (spec.state === "draft") createArgs.push("--draft");
|
||||
|
||||
const prUrl = gh(createArgs, { cwd: basePath });
|
||||
const prNumber = parseInt(prUrl.split("/").pop() ?? "0", 10);
|
||||
|
||||
if (spec.checks && spec.checks.length > 0) {
|
||||
const sha = git(["rev-parse", branch], basePath);
|
||||
for (const check of spec.checks) {
|
||||
gh([
|
||||
"api",
|
||||
`repos/${fullName}/statuses/${sha}`,
|
||||
"--method",
|
||||
"POST",
|
||||
"-f",
|
||||
`state=${check.state}`,
|
||||
"-f",
|
||||
`context=${check.context}`,
|
||||
"-f",
|
||||
`target_url=https://example.com/${encodeURIComponent(check.context)}`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let j = 0; j < (spec.commentCount ?? 0); j++) {
|
||||
gh(["pr", "comment", String(prNumber), "--body", `Test comment ${j + 1}`], { cwd: basePath });
|
||||
}
|
||||
|
||||
if (spec.state === "merged") {
|
||||
gh(["pr", "merge", String(prNumber), "--merge"], { cwd: basePath });
|
||||
} else if (spec.state === "closed") {
|
||||
gh(["pr", "close", String(prNumber)], { cwd: basePath });
|
||||
}
|
||||
|
||||
const localPath = await mkdtemp(path.join("/tmp", `${repoName}-ws-${index}-`));
|
||||
git(["clone", authedUrl, localPath, "--quiet", "-b", branch], basePath);
|
||||
// Clean remote URL (no embedded token) so gh can parse owner/repo
|
||||
git(["remote", "set-url", "origin", `https://github.com/${fullName}.git`], localPath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], localPath);
|
||||
git(["config", "user.name", "Paseo E2E"], localPath);
|
||||
git(["config", "commit.gpgsign", "false"], localPath);
|
||||
|
||||
return {
|
||||
fixture: { number: prNumber, title: spec.title, url: prUrl, branch, localPath },
|
||||
localPath,
|
||||
};
|
||||
}
|
||||
|
||||
function seedIssue(args: { spec: IssueSpec; basePath: string }): GhIssueFixture {
|
||||
const { spec, basePath } = args;
|
||||
const createArgs = ["issue", "create", "--title", spec.title, "--body", spec.body ?? ""];
|
||||
for (const label of spec.labels ?? []) {
|
||||
createArgs.push("--label", label);
|
||||
}
|
||||
const issueUrl = gh(createArgs, { cwd: basePath });
|
||||
const issueNumber = parseInt(issueUrl.split("/").pop() ?? "0", 10);
|
||||
if (spec.state === "closed") {
|
||||
gh(["issue", "close", String(issueNumber)], { cwd: basePath });
|
||||
}
|
||||
return { number: issueNumber, title: spec.title, url: issueUrl };
|
||||
}
|
||||
|
||||
export async function createTempGithubRepo(options: {
|
||||
prefix?: string;
|
||||
prs?: PrSpec[];
|
||||
issues?: IssueSpec[];
|
||||
}): Promise<GhRepoFixture> {
|
||||
const { prefix = "paseo-e2e-", prs = [], issues = [] } = options;
|
||||
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const repoName = `${prefix}${uniqueSuffix}`;
|
||||
|
||||
// Bootstrap local git repo
|
||||
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
|
||||
git(["init", "-b", "main"], basePath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], basePath);
|
||||
git(["config", "user.name", "Paseo E2E"], basePath);
|
||||
git(["config", "commit.gpgsign", "false"], basePath);
|
||||
await writeFile(path.join(basePath, "README.md"), "# E2E Test Repo\n");
|
||||
git(["add", "README.md"], basePath);
|
||||
git(["commit", "-m", "Initial commit"], basePath);
|
||||
|
||||
// Create GitHub repo and push initial commit
|
||||
gh(["repo", "create", repoName, "--private", `--source=${basePath}`, "--push"]);
|
||||
|
||||
const owner = gh(["api", "user", "--jq", ".login"]);
|
||||
const fullName = `${owner}/${repoName}`;
|
||||
const token = gh(["auth", "token"]);
|
||||
const authedUrl = `https://x-access-token:${token}@github.com/${fullName}.git`;
|
||||
|
||||
// Switch remote to authed URL for subsequent pushes
|
||||
git(["remote", "set-url", "origin", authedUrl], basePath);
|
||||
|
||||
// Create a branch + commit for each PR spec
|
||||
const branches: string[] = [];
|
||||
for (let i = 0; i < prs.length; i++) {
|
||||
const branch = `pr-branch-${i + 1}`;
|
||||
branches.push(branch);
|
||||
git(["checkout", "-b", branch], basePath);
|
||||
await writeFile(path.join(basePath, `pr-${i + 1}.txt`), `PR ${i + 1}\n`);
|
||||
git(["add", `pr-${i + 1}.txt`], basePath);
|
||||
git(["commit", "-m", `Add PR ${i + 1}`], basePath);
|
||||
git(["checkout", "main"], basePath);
|
||||
}
|
||||
|
||||
if (branches.length > 0) {
|
||||
git(["push", "origin", ...branches], basePath);
|
||||
}
|
||||
|
||||
// Create PRs, seed checks/comments, apply state changes, clone workspaces
|
||||
const prFixtures: GhPrFixture[] = [];
|
||||
const localPaths: string[] = [];
|
||||
|
||||
for (let i = 0; i < prs.length; i++) {
|
||||
const { fixture, localPath } = await seedPr({
|
||||
spec: prs[i],
|
||||
branch: branches[i],
|
||||
index: i,
|
||||
basePath,
|
||||
authedUrl,
|
||||
fullName,
|
||||
repoName,
|
||||
});
|
||||
localPaths.push(localPath);
|
||||
prFixtures.push(fixture);
|
||||
}
|
||||
|
||||
// Create issues
|
||||
const issueFixtures: GhIssueFixture[] = [];
|
||||
for (const spec of issues) {
|
||||
issueFixtures.push(seedIssue({ spec, basePath }));
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
name: repoName,
|
||||
fullName,
|
||||
prs: prFixtures,
|
||||
issues: issueFixtures,
|
||||
cleanup: async () => {
|
||||
try {
|
||||
gh(["repo", "delete", fullName, "--yes"]);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
await Promise.all([
|
||||
rm(basePath, { recursive: true, force: true }),
|
||||
...localPaths.map((p) => rm(p, { recursive: true, force: true })),
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -202,45 +202,12 @@ export async function expectStartingRefPickerTriggerPr(
|
||||
page: Page,
|
||||
input: { number: number; title: string; headRef: string },
|
||||
): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Starting ref" });
|
||||
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||
await expect(trigger).toContainText(`#${input.number}`);
|
||||
await expect(trigger).toContainText(input.title);
|
||||
await expect(trigger).not.toContainText(input.headRef);
|
||||
}
|
||||
|
||||
export async function openBranchPicker(page: Page): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Starting ref" });
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
}
|
||||
|
||||
export async function selectPickerOptionByKeyboard(page: Page, label: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder("Search branches and PRs");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await page.keyboard.type(label);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
export async function closeBranchPicker(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function expectPickerOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectPickerClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectPickerSelected(page: Page, label: string): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Starting ref" });
|
||||
await expect(trigger).toContainText(label);
|
||||
}
|
||||
|
||||
export async function expectComposerGithubAttachmentPill(
|
||||
page: Page,
|
||||
input: { number: number; title: string },
|
||||
@@ -283,110 +250,3 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
|
||||
return { workspaceId };
|
||||
}
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getStringField(input: Record<string, unknown>, key: string): string | null {
|
||||
const value = input[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
export interface AgentCreatedDelayControl {
|
||||
release(): void;
|
||||
waitForCreateRequest(): Promise<void>;
|
||||
waitForDelayedCreatedStatus(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function delayBrowserAgentCreatedStatus(
|
||||
page: Page,
|
||||
): Promise<AgentCreatedDelayControl> {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
|
||||
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
let resolveCreateRequest: (() => void) | null = null;
|
||||
let resolveDelayedCreatedStatus: (() => void) | null = null;
|
||||
const createRequestSeen = new Promise<void>((resolve) => {
|
||||
resolveCreateRequest = resolve;
|
||||
});
|
||||
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
|
||||
resolveDelayedCreatedStatus = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
const requestId = getStringField(sessionMessage, "requestId");
|
||||
if (requestId) {
|
||||
createRequestIds.add(requestId);
|
||||
resolveCreateRequest?.();
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload =
|
||||
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
|
||||
? (sessionMessage.payload as Record<string, unknown>)
|
||||
: null;
|
||||
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||
|
||||
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
|
||||
resolveDelayedCreatedStatus?.();
|
||||
if (releaseRequested) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreateRequest: () => createRequestSeen,
|
||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getStateLabel } from "@/utils/pr-pane-data";
|
||||
|
||||
export async function openPrPane(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByTestId("explorer-tab-pr").click();
|
||||
await expect(page.getByTestId("pr-pane")).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectPrPaneTitle(page: Page, title: string): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-title")).toContainText(title, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectPrPaneState(
|
||||
page: Page,
|
||||
state: "open" | "merged" | "closed" | "draft",
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCheckPill(page: Page, testId: string, count: number): Promise<void> {
|
||||
const locator = page.getByTestId(testId);
|
||||
await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 });
|
||||
if (count > 0) {
|
||||
await expect(locator).toContainText(String(count));
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectPrPaneCheckSummary(
|
||||
page: Page,
|
||||
counts: { passed: number; failed: number; pending: number },
|
||||
): Promise<void> {
|
||||
await assertCheckPill(page, "pr-pane-check-passed", counts.passed);
|
||||
await assertCheckPill(page, "pr-pane-check-failed", counts.failed);
|
||||
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
|
||||
}
|
||||
|
||||
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import { chmod, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export async function openProjects(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
}
|
||||
|
||||
export async function openProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function navigateToProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
}
|
||||
|
||||
// --- Form interactions ---
|
||||
|
||||
export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
|
||||
await page
|
||||
.getByRole("textbox", { name: "Worktree setup commands" })
|
||||
.fill(setupCommands.join("\n"));
|
||||
}
|
||||
|
||||
export async function clickSaveProjectSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save project config" }).click();
|
||||
}
|
||||
|
||||
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
|
||||
// action-0 is always "Try again"; action-1 is always "Reload".
|
||||
// The write-failed callout renders these two buttons in a fixed order.
|
||||
await page.getByTestId("write-failed-callout-action-0").click();
|
||||
}
|
||||
|
||||
export async function clickReloadProjectSettings(page: Page): Promise<void> {
|
||||
// Scope to the active error callout so the locator is unambiguous.
|
||||
// At most one error callout renders at a time.
|
||||
await page.locator('[data-testid$="-callout"]').getByRole("button", { name: "Reload" }).click();
|
||||
}
|
||||
|
||||
// --- Error-state assertions ---
|
||||
|
||||
type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed";
|
||||
|
||||
const errorCalloutTestId: Record<ErrorKind, string> = {
|
||||
stale: "stale-callout",
|
||||
invalid: "invalid-callout",
|
||||
write_failed: "write-failed-callout",
|
||||
transport: "read-transport-callout",
|
||||
read_failed: "read-failed-callout",
|
||||
};
|
||||
|
||||
export async function expectProjectSettingsError(page: Page, kind: ErrorKind): Promise<void> {
|
||||
await expect(page.getByTestId(errorCalloutTestId[kind])).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectNoProjectSettingsError(
|
||||
page: Page,
|
||||
kind: ErrorKind,
|
||||
timeout = 15_000,
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId(errorCalloutTestId[kind])).not.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export async function expectWriteFailedCalloutActions(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("write-failed-callout-action-0")).toHaveText("Try again");
|
||||
await expect(page.getByTestId("write-failed-callout-action-1")).toHaveText("Reload");
|
||||
}
|
||||
|
||||
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Save project config" })).toBeDisabled();
|
||||
}
|
||||
|
||||
// --- Form-state assertions ---
|
||||
|
||||
export async function expectProjectSettingsFormVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectProjectSettingsFormHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectNoEditableTarget(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
// --- Host-section assertions ---
|
||||
|
||||
export async function expectHostIndicatorVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-indicator")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostPickerHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-picker")).not.toBeVisible();
|
||||
}
|
||||
|
||||
// --- Script-list assertions and interactions ---
|
||||
|
||||
// Counts only row Views, not kebab-trigger elements (which share the "script-row-"
|
||||
// prefix but contain "-menu-").
|
||||
export async function expectScriptRowCount(page: Page, count: number): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("scripts-list")
|
||||
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])'),
|
||||
).toHaveCount(count);
|
||||
}
|
||||
|
||||
export async function expectEmptyScriptList(page: Page): Promise<void> {
|
||||
await expect(page.getByText("No scripts yet.")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function removeProjectScript(page: Page, scriptName: string): Promise<void> {
|
||||
const row = page
|
||||
.getByTestId("scripts-list")
|
||||
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])')
|
||||
.filter({ hasText: scriptName })
|
||||
.first();
|
||||
// DropdownMenuTrigger renders as a Pressable (no role="button"); derive its testID
|
||||
// from the row's testID to avoid scoped locator unreliability.
|
||||
const id = (await row.getAttribute("data-testid"))!.replace("script-row-", "");
|
||||
await page.getByTestId(`script-row-menu-${id}`).click();
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await page.getByRole("button", { name: "Remove" }).click();
|
||||
}
|
||||
|
||||
// --- File manipulation ---
|
||||
|
||||
export async function corruptPaseoConfig(repoPath: string): Promise<void> {
|
||||
await writeFile(path.join(repoPath, "paseo.json"), "{not valid json}");
|
||||
}
|
||||
|
||||
export async function bumpPaseoConfigOnDisk(repoPath: string): Promise<void> {
|
||||
const configPath = path.join(repoPath, "paseo.json");
|
||||
const raw = await readFile(configPath, "utf8");
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
config._bump = Date.now();
|
||||
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
|
||||
}
|
||||
|
||||
export async function restorePaseoConfig(
|
||||
repoPath: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await writeFile(path.join(repoPath, "paseo.json"), JSON.stringify(config, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// The daemon writes atomically via a temp file + rename, so blocking writes requires
|
||||
// removing write permission from the *directory*, not just the file.
|
||||
export async function blockPaseoConfigWrites(repoPath: string): Promise<void> {
|
||||
await chmod(repoPath, 0o555);
|
||||
}
|
||||
|
||||
export async function unblockPaseoConfigWrites(repoPath: string): Promise<void> {
|
||||
await chmod(repoPath, 0o755);
|
||||
}
|
||||
|
||||
// --- WebSocket helpers ---
|
||||
|
||||
function buildDaemonPortPattern(): RegExp {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
|
||||
return new RegExp(`:${port.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
}
|
||||
|
||||
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
||||
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
||||
// connections pass through so the Reload action can succeed.
|
||||
export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
let armed = true;
|
||||
|
||||
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (armed && typeof message === "string") {
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: string;
|
||||
message?: { type?: string };
|
||||
};
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "read_project_config_request"
|
||||
) {
|
||||
armed = false;
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// binary or malformed frame — pass through
|
||||
}
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
// server socket already closed
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
// client socket already closed
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Installs a transparent WS proxy that can later drop all active daemon connections
|
||||
// and block new ones. Code 1001 (Going Away) without reason triggers "error" state
|
||||
// in DaemonClient due to describeTransportClose returning a non-empty string.
|
||||
export async function installDaemonConnectionGate(
|
||||
page: Page,
|
||||
): Promise<{ drop: () => Promise<void> }> {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
|
||||
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
|
||||
activeSockets.add(ws);
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async drop(): Promise<void> {
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
await Promise.all(sockets.map((ws) => ws.close({ code: 1001 }).catch(() => undefined)));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -19,21 +19,3 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
|
||||
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByTestId("menu-button").click();
|
||||
}
|
||||
|
||||
// force=true: the overlay covers the button when the mobile sidebar is open.
|
||||
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByTestId("menu-button").click({ force: true });
|
||||
}
|
||||
|
||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
clickNewWorkspaceButton,
|
||||
closeBranchPicker,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
delayBrowserAgentCreatedStatus,
|
||||
expectComposerGithubAttachmentPill,
|
||||
expectPickerClosed,
|
||||
expectPickerOpen,
|
||||
expectPickerSelected,
|
||||
expectStartingRefPickerTriggerPr,
|
||||
openBranchPicker,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
openProjectViaDaemon,
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import {
|
||||
@@ -35,6 +28,108 @@ import {
|
||||
workspaceLabelFromPath,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getStringField(input: Record<string, unknown>, key: string): string | null {
|
||||
const value = input[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
async function delayBrowserAgentCreatedStatus(page: Page) {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
|
||||
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
let resolveCreateRequest: (() => void) | null = null;
|
||||
let resolveDelayedCreatedStatus: (() => void) | null = null;
|
||||
const createRequestSeen = new Promise<void>((resolve) => {
|
||||
resolveCreateRequest = resolve;
|
||||
});
|
||||
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
|
||||
resolveDelayedCreatedStatus = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
const requestId = getStringField(sessionMessage, "requestId");
|
||||
if (requestId) {
|
||||
createRequestIds.add(requestId);
|
||||
resolveCreateRequest?.();
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload =
|
||||
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
|
||||
? (sessionMessage.payload as Record<string, unknown>)
|
||||
: null;
|
||||
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||
|
||||
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
|
||||
resolveDelayedCreatedStatus?.();
|
||||
if (releaseRequested) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreateRequest: () => createRequestSeen,
|
||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
@@ -410,55 +505,6 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("branch picker opens via keyboard, navigates options, and selects on Enter", async ({
|
||||
page,
|
||||
}) => {
|
||||
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await selectPickerOptionByKeyboard(page, "dev");
|
||||
await expectPickerSelected(page, "dev");
|
||||
await expectPickerClosed(page);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("branch picker closes on Escape without selecting an option", async ({ page }) => {
|
||||
const tempRepo = await createTempGitRepo("picker-escape-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await closeBranchPicker(page);
|
||||
await expectPickerClosed(page);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
|
||||
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
openPrPane,
|
||||
expectPrPaneTitle,
|
||||
expectPrPaneState,
|
||||
expectPrPaneCheckSummary,
|
||||
expectPrPaneActivityCount,
|
||||
} from "./helpers/pr-pane";
|
||||
import { gotoWorkspace } from "./helpers/launcher";
|
||||
import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
type WorkspaceSetupDaemonClient,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
const GITHUB_AUTH = hasGithubAuth();
|
||||
|
||||
test.describe("PR pane", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
let seedClient: WorkspaceSetupDaemonClient;
|
||||
let repoFixture: GhRepoFixture;
|
||||
const workspaceByTitle = new Map<string, string>();
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (!GITHUB_AUTH) return;
|
||||
|
||||
seedClient = await connectWorkspaceSetupClient();
|
||||
|
||||
repoFixture = await createTempGithubRepo({
|
||||
prefix: "paseo-e2e-pr-",
|
||||
prs: [
|
||||
{ title: "Review selected start ref", state: "open" },
|
||||
{ title: "Merged feature branch", state: "merged" },
|
||||
{ title: "Closed without merge", state: "closed" },
|
||||
{ title: "Work in progress", state: "draft" },
|
||||
{
|
||||
title: "PR with mixed checks",
|
||||
state: "open",
|
||||
checks: [
|
||||
{ context: "build-1", state: "success" },
|
||||
{ context: "build-2", state: "success" },
|
||||
{ context: "deploy", state: "failure" },
|
||||
{ context: "security", state: "pending" },
|
||||
],
|
||||
},
|
||||
{ title: "PR with reviews", state: "open", commentCount: 3 },
|
||||
{ title: "PR with no checks", state: "open" },
|
||||
],
|
||||
});
|
||||
|
||||
for (const pr of repoFixture.prs) {
|
||||
const result = await seedClient.openProject(pr.localPath);
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? `Failed to open project ${pr.localPath}`);
|
||||
}
|
||||
workspaceByTitle.set(pr.title, result.workspace.id);
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await repoFixture?.cleanup().catch(() => undefined);
|
||||
await seedClient?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
test.skip(!GITHUB_AUTH, "Requires GitHub authentication (gh auth login)");
|
||||
test.setTimeout(60_000);
|
||||
});
|
||||
|
||||
test("renders an open PR with title, state, and repo line", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("Review selected start ref")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneTitle(page, "Review selected start ref");
|
||||
await expectPrPaneState(page, "open");
|
||||
});
|
||||
|
||||
test("renders merged state label and icon", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("Merged feature branch")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneState(page, "merged");
|
||||
await expectPrPaneTitle(page, "Merged feature branch");
|
||||
});
|
||||
|
||||
test("renders closed state label and icon", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("Closed without merge")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneState(page, "closed");
|
||||
await expectPrPaneTitle(page, "Closed without merge");
|
||||
});
|
||||
|
||||
test("renders draft state label and icon", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("Work in progress")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneState(page, "draft");
|
||||
await expectPrPaneTitle(page, "Work in progress");
|
||||
});
|
||||
|
||||
test("renders check pills with correct passed/failed/pending counts", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { passed: 2, failed: 1, pending: 1 });
|
||||
});
|
||||
|
||||
test("renders activity rows with correct count", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneActivityCount(page, 3);
|
||||
});
|
||||
|
||||
test("renders gracefully with zero checks", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { passed: 0, failed: 0, pending: 0 });
|
||||
await expectPrPaneTitle(page, "PR with no checks");
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,9 @@
|
||||
import { chmod, readFile } from "node:fs/promises";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test as base } from "./fixtures";
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
blockPaseoConfigWrites,
|
||||
bumpPaseoConfigOnDisk,
|
||||
clickReloadProjectSettings,
|
||||
clickRetryProjectSettingsSave,
|
||||
clickSaveProjectSettings,
|
||||
corruptPaseoConfig,
|
||||
editWorktreeSetup,
|
||||
expectEmptyScriptList,
|
||||
expectHostIndicatorVisible,
|
||||
expectHostPickerHidden,
|
||||
expectNoEditableTarget,
|
||||
expectNoProjectSettingsError,
|
||||
expectProjectSettingsError,
|
||||
expectProjectSettingsFormHidden,
|
||||
expectProjectSettingsFormVisible,
|
||||
expectSaveButtonDisabled,
|
||||
expectScriptRowCount,
|
||||
expectWriteFailedCalloutActions,
|
||||
installDaemonConnectionGate,
|
||||
installReadTransportFailure,
|
||||
navigateToProjectSettings,
|
||||
openProjectSettings,
|
||||
openProjects,
|
||||
removeProjectScript,
|
||||
restorePaseoConfig,
|
||||
unblockPaseoConfigWrites,
|
||||
} from "./helpers/project-settings";
|
||||
|
||||
const updatedSetup = ["npm install", "npm run build"];
|
||||
|
||||
@@ -75,9 +48,6 @@ const test = base.extend<ProjectsSettingsFixtures>({
|
||||
});
|
||||
|
||||
await client.close();
|
||||
// Defensive: restore directory write permission in case the test left it blocked
|
||||
// (write_failed test), so that repo.cleanup() can remove files inside.
|
||||
await chmod(repo.path, 0o755).catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
},
|
||||
gitlabRemoteProject: async ({ page: _page }, provide) => {
|
||||
@@ -98,6 +68,30 @@ const test = base.extend<ProjectsSettingsFixtures>({
|
||||
},
|
||||
});
|
||||
|
||||
async function openProjects(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
}
|
||||
|
||||
async function openProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
|
||||
await page
|
||||
.getByRole("textbox", { name: "Worktree setup commands" })
|
||||
.fill(setupCommands.join("\n"));
|
||||
}
|
||||
|
||||
async function saveProjectConfig(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save project config" }).click();
|
||||
}
|
||||
|
||||
async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -139,7 +133,7 @@ test.describe("Projects settings", () => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
await clickSaveProjectSettings(page);
|
||||
await saveProjectConfig(page);
|
||||
await expectProjectConfigSaved(editableProject);
|
||||
});
|
||||
|
||||
@@ -151,138 +145,7 @@ test.describe("Projects settings", () => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, gitlabRemoteProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
await clickSaveProjectSettings(page);
|
||||
await saveProjectConfig(page);
|
||||
await expectProjectConfigSaved(gitlabRemoteProject);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Projects settings — error UX", () => {
|
||||
test("stale-write callout appears on save, disables save, and reload clears it", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
// Bump the file on disk so the daemon detects a revision mismatch on save.
|
||||
await bumpPaseoConfigOnDisk(editableProject.path);
|
||||
|
||||
await clickSaveProjectSettings(page);
|
||||
|
||||
await expectProjectSettingsError(page, "stale");
|
||||
await expectSaveButtonDisabled(page);
|
||||
|
||||
await clickReloadProjectSettings(page);
|
||||
|
||||
await expectNoProjectSettingsError(page, "stale");
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
});
|
||||
|
||||
test("invalid paseo.json shows read-error callout, reload after fix shows form", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await corruptPaseoConfig(editableProject.path);
|
||||
|
||||
await openProjects(page);
|
||||
await navigateToProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectProjectSettingsError(page, "invalid");
|
||||
await expectProjectSettingsFormHidden(page);
|
||||
|
||||
// Restore a valid config so the reload succeeds.
|
||||
await restorePaseoConfig(editableProject.path, initialPaseoConfig);
|
||||
|
||||
await clickReloadProjectSettings(page);
|
||||
|
||||
await expectNoProjectSettingsError(page, "invalid");
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
});
|
||||
|
||||
test("write_failed callout appears on save with blocked directory, retry re-attempts, reload clears it", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await blockPaseoConfigWrites(editableProject.path);
|
||||
|
||||
await clickSaveProjectSettings(page);
|
||||
|
||||
await expectProjectSettingsError(page, "write_failed");
|
||||
await expectWriteFailedCalloutActions(page);
|
||||
|
||||
await clickRetryProjectSettingsSave(page);
|
||||
await expectProjectSettingsError(page, "write_failed");
|
||||
|
||||
await unblockPaseoConfigWrites(editableProject.path);
|
||||
await clickReloadProjectSettings(page);
|
||||
await expectNoProjectSettingsError(page, "write_failed");
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
});
|
||||
|
||||
test("read-transport failure shows callout, reload recovers", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
// Drop the WS connection the moment a read_project_config_request is sent.
|
||||
// Subsequent connections are proxied transparently so Reload can succeed.
|
||||
await installReadTransportFailure(page);
|
||||
|
||||
await openProjects(page);
|
||||
await navigateToProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectProjectSettingsError(page, "transport");
|
||||
await expectProjectSettingsFormHidden(page);
|
||||
|
||||
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
|
||||
await expect(async () => {
|
||||
await clickReloadProjectSettings(page);
|
||||
await expectNoProjectSettingsError(page, "transport", 3_000);
|
||||
}).toPass({ timeout: 15_000 });
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
});
|
||||
|
||||
test("project settings shows no-target state when daemon connection drops", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
const gate = await installDaemonConnectionGate(page);
|
||||
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
// Closing with code 1001 (Going Away) transitions DaemonClient to "error" state.
|
||||
// The NoEditableTarget UI renders via isHostGone check regardless of state.
|
||||
await gate.drop();
|
||||
|
||||
await expectNoEditableTarget(page);
|
||||
});
|
||||
|
||||
test("single-host project renders static host indicator, not a picker chip", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectHostIndicatorVisible(page);
|
||||
await expectHostPickerHidden(page);
|
||||
});
|
||||
|
||||
test("script removal via kebab menu removes the row from the form", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectScriptRowCount(page, 1);
|
||||
|
||||
await removeProjectScript(page, "dev");
|
||||
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,6 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
} from "./helpers/sidebar";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
|
||||
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
|
||||
@@ -186,16 +180,3 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile sidebar panelState transition", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("showMobileAgent open and close transition", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -11,8 +11,6 @@ export function getStatusSelectorHint(selector: ExplainedStatusSelector): string
|
||||
return "Change model";
|
||||
case "mode":
|
||||
return "Change permission mode";
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const IS_WEB = platformIsWeb;
|
||||
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
|
||||
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
||||
const EMPTY_COMBOBOX_OPTIONS: ReadonlyArray<ComboboxOption> = [];
|
||||
|
||||
function noop() {}
|
||||
|
||||
@@ -446,11 +446,12 @@ function ProviderSearchInput({
|
||||
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
if (autoFocus && platformIsWeb && inputRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
const inputStyle = useMemo(
|
||||
@@ -698,12 +699,12 @@ export function CombinedModelSelector({
|
||||
|
||||
useEffect(() => {
|
||||
if (platformIsWeb) {
|
||||
return () => {};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
setIsContentReady(false);
|
||||
return () => {};
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
@@ -790,7 +791,7 @@ export function CombinedModelSelector({
|
||||
)}
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={EMPTY_COMBOBOX_OPTIONS}
|
||||
options={EMPTY_COMBOBOX_OPTIONS as ComboboxOption[]}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
open={isOpen}
|
||||
|
||||
@@ -461,7 +461,6 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
|
||||
onPress={handleEdit}
|
||||
style={styles.queueActionButton}
|
||||
accessibilityLabel="Edit queued message"
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
|
||||
</Pressable>
|
||||
@@ -469,7 +468,6 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
|
||||
onPress={handleSendNow}
|
||||
style={QUEUE_SEND_BUTTON_STYLE}
|
||||
accessibilityLabel="Send queued message now"
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ArrowUp size={ICON_SIZE.sm} color="white" />
|
||||
</Pressable>
|
||||
|
||||
@@ -308,7 +308,7 @@ function HostPickerTrigger({
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
Boolean(hovered) && styles.hostTriggerHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -91,17 +91,15 @@ export function PrPane({ data }: { data: PrPaneData }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane">
|
||||
<View style={styles.root}>
|
||||
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<View style={styles.stateLine}>
|
||||
<StateIcon size={14} color={stateColor} />
|
||||
<Text style={stateLabelStyle} testID="pr-pane-state">
|
||||
{stateLabel}
|
||||
</Text>
|
||||
<Text style={stateLabelStyle}>{stateLabel}</Text>
|
||||
</View>
|
||||
<Text style={styles.title} numberOfLines={3} testID="pr-pane-title">
|
||||
<Text style={styles.title} numberOfLines={3}>
|
||||
{data.title}
|
||||
{hovered ? (
|
||||
<Text>
|
||||
@@ -126,19 +124,12 @@ export function PrPane({ data }: { data: PrPaneData }) {
|
||||
count={passed}
|
||||
color={theme.colors.statusSuccess}
|
||||
icon={checkSuccessIcon}
|
||||
testID="pr-pane-check-passed"
|
||||
/>
|
||||
<SummaryPill
|
||||
count={failed}
|
||||
color={theme.colors.statusDanger}
|
||||
icon={checkDangerIcon}
|
||||
testID="pr-pane-check-failed"
|
||||
/>
|
||||
<SummaryPill count={failed} color={theme.colors.statusDanger} icon={checkDangerIcon} />
|
||||
<SummaryPill
|
||||
count={pending}
|
||||
color={theme.colors.statusWarning}
|
||||
icon={checkWarningIcon}
|
||||
testID="pr-pane-check-pending"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
@@ -220,17 +211,15 @@ function SummaryPill({
|
||||
count,
|
||||
color,
|
||||
icon,
|
||||
testID,
|
||||
}: {
|
||||
count: number;
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
testID?: string;
|
||||
}) {
|
||||
const textStyle = useMemo(() => [styles.summaryPillText, { color }], [color]);
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<View style={styles.summaryPill} testID={testID}>
|
||||
<View style={styles.summaryPill}>
|
||||
{icon}
|
||||
<Text style={textStyle}>{count}</Text>
|
||||
</View>
|
||||
@@ -275,7 +264,7 @@ function ActivityRow({ item }: { item: PrPaneActivity }) {
|
||||
[item.avatarColor],
|
||||
);
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={activityPressableStyle} testID="pr-pane-activity-row">
|
||||
<Pressable onPress={handlePress} style={activityPressableStyle}>
|
||||
<View style={avatarStyle}>
|
||||
<Text style={styles.avatarText}>{item.author.slice(0, 1).toUpperCase()}</Text>
|
||||
</View>
|
||||
|
||||
@@ -72,7 +72,7 @@ function AutocompleteRow({
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.item,
|
||||
(hovered || pressed || isSelected) && styles.itemActive,
|
||||
(Boolean(hovered) || pressed || isSelected) && styles.itemActive,
|
||||
],
|
||||
[isSelected],
|
||||
);
|
||||
|
||||
@@ -315,7 +315,7 @@ export function DropdownMenuTrigger({
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
if (typeof style === "function") {
|
||||
return style({ pressed, hovered, open: ctx.open });
|
||||
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
|
||||
}
|
||||
return style;
|
||||
},
|
||||
@@ -324,7 +324,7 @@ export function DropdownMenuTrigger({
|
||||
|
||||
const renderChildren = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
const state: TriggerState = { pressed, hovered, open: ctx.open };
|
||||
const state: TriggerState = { pressed, hovered: Boolean(hovered), open: ctx.open };
|
||||
return typeof children === "function" ? children(state) : children;
|
||||
},
|
||||
[children, ctx.open],
|
||||
@@ -445,7 +445,7 @@ export function DropdownMenuContent({
|
||||
setTriggerRect(null);
|
||||
setContentSize(null);
|
||||
setPosition(null);
|
||||
return undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture status bar height synchronously before async measurement.
|
||||
@@ -454,8 +454,8 @@ export function DropdownMenuContent({
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
let cancelled = false;
|
||||
|
||||
void measureElement(triggerRef.current).then((rect) => {
|
||||
if (cancelled) return undefined;
|
||||
measureElement(triggerRef.current).then((rect) => {
|
||||
if (cancelled) return;
|
||||
// On Android with statusBarTranslucent, measureInWindow returns coordinates
|
||||
// relative to below the status bar, but Modal content starts from screen top.
|
||||
// Add status bar height to align coordinate systems (same as react-native-popover-view).
|
||||
@@ -463,7 +463,7 @@ export function DropdownMenuContent({
|
||||
...rect,
|
||||
y: rect.y + statusBarHeight,
|
||||
});
|
||||
return undefined;
|
||||
return;
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -32,7 +32,6 @@ vi.mock("react-native-reanimated", () => ({
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
Pressable: ({
|
||||
"aria-checked": ariaChecked,
|
||||
accessibilityLabel,
|
||||
accessibilityRole,
|
||||
accessibilityState,
|
||||
@@ -41,7 +40,6 @@ vi.mock("react-native", () => ({
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
"aria-checked"?: boolean;
|
||||
accessibilityLabel?: string;
|
||||
accessibilityRole?: string;
|
||||
accessibilityState?: { checked?: boolean; disabled?: boolean };
|
||||
@@ -53,7 +51,7 @@ vi.mock("react-native", () => ({
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
"aria-checked": ariaChecked ?? accessibilityState?.checked,
|
||||
"aria-checked": accessibilityState?.checked,
|
||||
"aria-disabled": accessibilityState?.disabled,
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-disabled": disabled,
|
||||
|
||||
@@ -99,8 +99,8 @@ export function Switch({
|
||||
hitSlop={8}
|
||||
accessibilityRole="switch"
|
||||
accessibilityState={accessibilityState}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
aria-checked={value}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
style={pressableStyle}
|
||||
>
|
||||
|
||||
@@ -865,7 +865,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}, [client, isConnected, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = voiceRuntime?.registerSession({
|
||||
if (!voiceRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
return voiceRuntime.registerSession({
|
||||
serverId,
|
||||
setVoiceMode: async (enabled, agentId) => {
|
||||
if (!client) {
|
||||
@@ -895,7 +899,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setIsPlayingAudio(serverId, isPlaying);
|
||||
},
|
||||
});
|
||||
return () => unregister?.();
|
||||
}, [client, serverId, setIsPlayingAudio, voiceRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -913,7 +916,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected) {
|
||||
return () => {};
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
@@ -1239,10 +1242,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (message.payload.kind === "remove") {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: message.payload.id,
|
||||
workspaceId: String(message.payload.id),
|
||||
});
|
||||
removeWorkspaceSetup({ serverId, workspaceId: message.payload.id });
|
||||
removeWorkspace(serverId, message.payload.id);
|
||||
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) });
|
||||
removeWorkspace(serverId, String(message.payload.id));
|
||||
return;
|
||||
}
|
||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||
@@ -1400,10 +1403,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
|
||||
if (data.type === "tool_call" && data.metadata) {
|
||||
const toolCallId =
|
||||
typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : "";
|
||||
const toolName = typeof data.metadata.toolName === "string" ? data.metadata.toolName : "";
|
||||
const args = data.metadata.arguments;
|
||||
const {
|
||||
toolCallId,
|
||||
toolName,
|
||||
arguments: args,
|
||||
} = data.metadata as {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
arguments: unknown;
|
||||
};
|
||||
|
||||
setMessages(serverId, (prev) => [
|
||||
...prev,
|
||||
@@ -1420,9 +1428,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
|
||||
if (data.type === "tool_result" && data.metadata) {
|
||||
const toolCallId =
|
||||
typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : "";
|
||||
const result = data.metadata.result;
|
||||
const { toolCallId, result } = data.metadata as {
|
||||
toolCallId: string;
|
||||
result: unknown;
|
||||
};
|
||||
|
||||
const applyToolResult = applyToolResultToMessages(toolCallId, result);
|
||||
setMessages(serverId, applyToolResult);
|
||||
@@ -1430,9 +1439,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
|
||||
if (data.type === "error" && data.metadata && "toolCallId" in data.metadata) {
|
||||
const toolCallId =
|
||||
typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : "";
|
||||
const error = data.metadata.error;
|
||||
const { toolCallId, error } = data.metadata as {
|
||||
toolCallId: string;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
const applyToolError = applyToolErrorToMessages(toolCallId, error);
|
||||
setMessages(serverId, applyToolError);
|
||||
@@ -1790,7 +1800,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
} catch (error) {
|
||||
console.error("[Session] Failed to prepare images for agent creation:", error);
|
||||
}
|
||||
await client.createAgent({
|
||||
return client.createAgent({
|
||||
config,
|
||||
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
|
||||
@@ -553,7 +553,5 @@ export function resolveAgentForm(
|
||||
|
||||
case "RESET":
|
||||
return { ...state, userModified: INITIAL_USER_MODIFIED };
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}, []);
|
||||
|
||||
const refreshProviderModels = useCallback(() => {
|
||||
void refreshSnapshot();
|
||||
refreshSnapshot();
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
const refetchProviderModelsIfStale = useCallback(() => {
|
||||
|
||||
@@ -141,7 +141,7 @@ export function useAgentHistory(options: {
|
||||
isLoading,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
hasMore: hasNextPage,
|
||||
hasMore: Boolean(hasNextPage),
|
||||
isLoadingMore: isFetchingNextPage,
|
||||
refreshAll,
|
||||
loadMore,
|
||||
|
||||
@@ -100,7 +100,7 @@ function isAgentArchiving(input: IsAgentArchivingInput): boolean {
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
return readPendingState(input.queryClient)[key] ?? false;
|
||||
return Boolean(readPendingState(input.queryClient)[key]);
|
||||
}
|
||||
|
||||
function removeAgentFromListPayload<T extends AgentsListQueryData | undefined>(
|
||||
@@ -420,7 +420,7 @@ export function useArchiveAgent() {
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
return (pendingQuery.data ?? {})[key] ?? false;
|
||||
return Boolean((pendingQuery.data ?? {})[key]);
|
||||
},
|
||||
[pendingQuery.data],
|
||||
);
|
||||
|
||||
@@ -1055,8 +1055,6 @@ function resolvePayload(
|
||||
return { delta: def.delta };
|
||||
case "message-input":
|
||||
return { kind: def.kind };
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,17 +7,17 @@ export interface PaneContextValue {
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
openTab: (target: WorkspaceTabTarget) => void;
|
||||
closeCurrentTab: () => void;
|
||||
retargetCurrentTab: (target: WorkspaceTabTarget) => void;
|
||||
openFileInWorkspace: (filePath: string) => void;
|
||||
openTab(target: WorkspaceTabTarget): void;
|
||||
closeCurrentTab(): void;
|
||||
retargetCurrentTab(target: WorkspaceTabTarget): void;
|
||||
openFileInWorkspace(filePath: string): void;
|
||||
}
|
||||
|
||||
export interface PaneFocusContextValue {
|
||||
isWorkspaceFocused: boolean;
|
||||
isPaneFocused: boolean;
|
||||
isInteractive: boolean;
|
||||
focusPane: () => void;
|
||||
focusPane(): void;
|
||||
}
|
||||
|
||||
const PaneContext = createContext<PaneContextValue | null>(null);
|
||||
|
||||
@@ -71,12 +71,6 @@ export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScr
|
||||
}
|
||||
}, [editableHosts, selectedServerId]);
|
||||
|
||||
const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId);
|
||||
const isHostGone =
|
||||
Boolean(selectedServerId) &&
|
||||
(selectedSnapshot?.connectionStatus === "offline" ||
|
||||
selectedSnapshot?.connectionStatus === "error");
|
||||
|
||||
const selectedHost = editableHosts.find((host) => host.serverId === selectedServerId);
|
||||
const client = useHostRuntimeClient(selectedHost?.serverId ?? "");
|
||||
|
||||
@@ -91,7 +85,6 @@ export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScr
|
||||
selectedHost={selectedHost}
|
||||
onSelectHost={setSelectedServerId}
|
||||
client={client}
|
||||
isHostGone={isHostGone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -146,7 +139,6 @@ interface ProjectSettingsBodyProps {
|
||||
selectedHost: ProjectHostEntry;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
client: DaemonClient;
|
||||
isHostGone: boolean;
|
||||
}
|
||||
|
||||
function ProjectSettingsBody({
|
||||
@@ -155,7 +147,6 @@ function ProjectSettingsBody({
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
client,
|
||||
isHostGone,
|
||||
}: ProjectSettingsBodyProps) {
|
||||
const queryKey = useMemo(
|
||||
() => ["project-config", selectedHost.serverId, selectedHost.repoRoot] as const,
|
||||
@@ -201,7 +192,6 @@ function ProjectSettingsBody({
|
||||
client,
|
||||
onReload: handleReload,
|
||||
hasMultipleHosts,
|
||||
isHostGone,
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
@@ -217,7 +207,6 @@ interface RenderContentInput {
|
||||
client: DaemonClient;
|
||||
onReload: () => void;
|
||||
hasMultipleHosts: boolean;
|
||||
isHostGone: boolean;
|
||||
}
|
||||
|
||||
function renderContent({
|
||||
@@ -230,7 +219,6 @@ function renderContent({
|
||||
client,
|
||||
onReload,
|
||||
hasMultipleHosts,
|
||||
isHostGone,
|
||||
}: RenderContentInput) {
|
||||
if (readQuery.isLoading) {
|
||||
return (
|
||||
@@ -262,10 +250,6 @@ function renderContent({
|
||||
);
|
||||
}
|
||||
|
||||
if (isHostGone) {
|
||||
return <NoEditableTarget />;
|
||||
}
|
||||
|
||||
if (!loadedConfig) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
|
||||
@@ -86,7 +86,7 @@ async function showDesktopConfirmDialog(input: ConfirmDialogInput): Promise<bool
|
||||
const desktopAsk = desktopApi.dialog?.ask;
|
||||
|
||||
if (typeof desktopAsk === "function") {
|
||||
return await desktopAsk(input.message, options);
|
||||
return Boolean(await desktopAsk(input.message, options));
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PaseoConfigRawSchema } from "@server/utils/paseo-config-schema";
|
||||
import type { PaseoConfigRaw } from "@server/shared/messages";
|
||||
import { applyDraftToConfig, configToDraft, type ProjectConfigDraft } from "./project-config-form";
|
||||
|
||||
@@ -103,7 +102,7 @@ describe("applyDraftToConfig", () => {
|
||||
});
|
||||
|
||||
it("preserves unknown top-level, worktree, and script entry fields on round-trip", () => {
|
||||
const base = PaseoConfigRawSchema.parse({
|
||||
const base = {
|
||||
worktree: {
|
||||
setup: "npm install",
|
||||
terminals: [{ name: "dev", command: "npm run dev" }],
|
||||
@@ -118,28 +117,28 @@ describe("applyDraftToConfig", () => {
|
||||
},
|
||||
},
|
||||
customTopLevel: "preserved",
|
||||
});
|
||||
} as unknown as PaseoConfigRaw;
|
||||
|
||||
const draft = configToDraft(base);
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
|
||||
expect((next as Record<string, unknown>).customTopLevel).toBe("preserved");
|
||||
expect((next.worktree as Record<string, unknown>).customWorktreeField).toBe("keep");
|
||||
expect((next.worktree as Record<string, unknown>).terminals).toEqual([
|
||||
expect((next as unknown as Record<string, unknown>).customTopLevel).toBe("preserved");
|
||||
expect((next.worktree as unknown as Record<string, unknown>).customWorktreeField).toBe("keep");
|
||||
expect((next.worktree as unknown as Record<string, unknown>).terminals).toEqual([
|
||||
{ name: "dev", command: "npm run dev" },
|
||||
]);
|
||||
const devEntry = (next.scripts ?? {}).dev as Record<string, unknown>;
|
||||
const devEntry = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
|
||||
expect(devEntry.customScriptField).toEqual({ nested: true });
|
||||
});
|
||||
|
||||
it("preserves all scripts on round-trip, including ones never edited in this session", () => {
|
||||
const base = PaseoConfigRawSchema.parse({
|
||||
const base = {
|
||||
scripts: {
|
||||
dev: { type: "long-running", command: "npm run dev", port: 3000, customDevField: "keep" },
|
||||
build: { command: ["npm", "run", "build"], customBuildField: { nested: 1 } },
|
||||
lint: { command: "npm run lint", type: "task" },
|
||||
},
|
||||
});
|
||||
} as unknown as PaseoConfigRaw;
|
||||
|
||||
const draft = configToDraft(base);
|
||||
// Edit only "dev". Leave "build" and "lint" untouched.
|
||||
@@ -151,37 +150,37 @@ describe("applyDraftToConfig", () => {
|
||||
const scripts = next.scripts ?? {};
|
||||
expect(Object.keys(scripts).sort()).toEqual(["build", "dev", "lint"]);
|
||||
|
||||
const devEntry = scripts.dev as Record<string, unknown>;
|
||||
const devEntry = scripts.dev as unknown as Record<string, unknown>;
|
||||
expect(devEntry.command).toBe("npm run dev -- --watch");
|
||||
expect(devEntry.type).toBe("long-running");
|
||||
expect(devEntry.port).toBe(3000);
|
||||
expect(devEntry.customDevField).toBe("keep");
|
||||
|
||||
const buildEntry = scripts.build as Record<string, unknown>;
|
||||
const buildEntry = scripts.build as unknown as Record<string, unknown>;
|
||||
expect(buildEntry.command).toEqual(["npm", "run", "build"]);
|
||||
expect(buildEntry.customBuildField).toEqual({ nested: 1 });
|
||||
|
||||
const lintEntry = scripts.lint as Record<string, unknown>;
|
||||
const lintEntry = scripts.lint as unknown as Record<string, unknown>;
|
||||
expect(lintEntry.command).toBe("npm run lint");
|
||||
expect(lintEntry.type).toBe("task");
|
||||
});
|
||||
|
||||
it("normalizes script command text into the original command kind", () => {
|
||||
const base = PaseoConfigRawSchema.parse({
|
||||
const base = {
|
||||
scripts: {
|
||||
build: { command: ["npm", "run", "build"] },
|
||||
},
|
||||
});
|
||||
} as unknown as PaseoConfigRaw;
|
||||
const draft = configToDraft(base);
|
||||
const buildRow = draft.scripts[0];
|
||||
buildRow.commandText = "npm run build";
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
const buildEntry = (next.scripts ?? {}).build as Record<string, unknown>;
|
||||
const buildEntry = (next.scripts ?? {}).build as unknown as Record<string, unknown>;
|
||||
expect(buildEntry.command).toEqual(["npm run build"]);
|
||||
});
|
||||
|
||||
it("parses script port as a number when numeric and writes string for non-numeric input", () => {
|
||||
const base = PaseoConfigRawSchema.parse({});
|
||||
const base: PaseoConfigRaw = {};
|
||||
const draft = configToDraft(base);
|
||||
draft.scripts = [
|
||||
{
|
||||
@@ -204,19 +203,19 @@ describe("applyDraftToConfig", () => {
|
||||
},
|
||||
];
|
||||
const next = applyDraftToConfig({ draft, base });
|
||||
const dev = (next.scripts ?? {}).dev as Record<string, unknown>;
|
||||
const tunnel = (next.scripts ?? {}).tunnel as Record<string, unknown>;
|
||||
const dev = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
|
||||
const tunnel = (next.scripts ?? {}).tunnel as unknown as Record<string, unknown>;
|
||||
expect(dev.port).toBe(3000);
|
||||
expect(tunnel.port).toBe("auto");
|
||||
});
|
||||
|
||||
it("drops scripts with an empty name and removes scripts no longer present in the draft", () => {
|
||||
const base = PaseoConfigRawSchema.parse({
|
||||
const base = {
|
||||
scripts: {
|
||||
dev: { command: "npm run dev" },
|
||||
build: { command: "npm run build" },
|
||||
},
|
||||
});
|
||||
} as unknown as PaseoConfigRaw;
|
||||
const draft = configToDraft(base);
|
||||
// remove build, add a row with empty name.
|
||||
draft.scripts = draft.scripts
|
||||
|
||||
@@ -112,7 +112,7 @@ export function getWorkspaceExecutionAuthority(
|
||||
reason: "workspace_missing",
|
||||
message:
|
||||
"workspaces" in input
|
||||
? `Workspace not found: ${input.workspaceId ?? ""}`
|
||||
? `Workspace not found: ${String(input.workspaceId ?? "")}`
|
||||
: "Workspace not found.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.69",
|
||||
"@getpaseo/server": "0.1.67",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
spawnSync: vi.fn(),
|
||||
spawnProcess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawnSync: mocks.spawnSync,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@getpaseo/server", async () => {
|
||||
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
|
||||
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
|
||||
spawnProcess: mocks.spawnProcess,
|
||||
};
|
||||
});
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
pid = 4242;
|
||||
unref = vi.fn();
|
||||
}
|
||||
|
||||
function expectSupervisorLaunch(argv: string[]): void {
|
||||
const joined = argv.join(" ");
|
||||
expect(joined).toContain("supervisor-entrypoint");
|
||||
expect(joined).not.toContain("src/server/index.ts");
|
||||
expect(joined).not.toContain("dist/server/server/index.js");
|
||||
expect(joined).not.toContain("src/server/daemon-worker.ts");
|
||||
expect(joined).not.toContain("dist/server/server/daemon-worker.js");
|
||||
}
|
||||
|
||||
describe("local daemon launch supervision", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mocks.spawnSync.mockReset();
|
||||
mocks.spawnProcess.mockReset();
|
||||
});
|
||||
|
||||
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
|
||||
|
||||
const { startLocalDaemonForeground } = await import("./local-daemon.js");
|
||||
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect(mocks.spawnSync).toHaveBeenCalledOnce();
|
||||
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
|
||||
expect(command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(argv);
|
||||
expect(argv).toContain("--no-relay");
|
||||
});
|
||||
|
||||
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = new FakeChildProcess();
|
||||
mocks.spawnProcess.mockReturnValue(child);
|
||||
|
||||
const { startLocalDaemonDetached } = await import("./local-daemon.js");
|
||||
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
|
||||
await vi.advanceTimersByTimeAsync(1200);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
|
||||
expect(child.unref).toHaveBeenCalledOnce();
|
||||
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
|
||||
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
|
||||
expect(command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(argv);
|
||||
expect(argv).toContain("--no-mcp");
|
||||
});
|
||||
});
|
||||
@@ -432,7 +432,7 @@ async function waitForDaemonReadyWithUi(args: {
|
||||
} else {
|
||||
log.error(message);
|
||||
}
|
||||
return process.exit(1);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,8 +123,6 @@ export function toDaemonCreateInput(parsed: ParsedWorktreeCreateInput) {
|
||||
action: "checkout" as const,
|
||||
githubPrNumber: parsed.target.prNumber,
|
||||
};
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Regression: executable daemon launch commands must enter the supervisor.
|
||||
* The worker entry remains an implementation detail of supervisor-entrypoint.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const repoRoot = join(import.meta.dirname, "../../..");
|
||||
const serverPackagePath = join(repoRoot, "packages/server/package.json");
|
||||
const appGlobalSetupPath = join(repoRoot, "packages/app/e2e/global-setup.ts");
|
||||
const serverConnectionOfferE2ePath = join(
|
||||
repoRoot,
|
||||
"packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts",
|
||||
);
|
||||
const desktopRuntimePathsPath = join(repoRoot, "packages/desktop/src/daemon/runtime-paths.ts");
|
||||
const nixPackagePath = join(repoRoot, "nix/package.nix");
|
||||
|
||||
function assertNoDirectWorkerLaunch(label: string, command: string): void {
|
||||
assert(
|
||||
!command.includes("src/server/index.ts"),
|
||||
`${label} must not launch src/server/index.ts directly: ${command}`,
|
||||
);
|
||||
assert(
|
||||
!command.includes("dist/server/server/index.js"),
|
||||
`${label} must not launch dist/server/server/index.js directly: ${command}`,
|
||||
);
|
||||
assert(
|
||||
!command.includes("src/server/daemon-worker.ts"),
|
||||
`${label} must not launch src/server/daemon-worker.ts directly: ${command}`,
|
||||
);
|
||||
assert(
|
||||
!command.includes("dist/server/server/daemon-worker.js"),
|
||||
`${label} must not launch dist/server/server/daemon-worker.js directly: ${command}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertNoSpawnedWorkerEntrypoint(label: string, source: string): void {
|
||||
assertNoDirectWorkerLaunch(label, source);
|
||||
assert(
|
||||
!/spawn\([^)]*["'`][^"'`]*\.\.\/index\.ts["'`]/s.test(source),
|
||||
`${label} must not spawn ../index.ts directly`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("=== Daemon Launch Supervision Regression ===\n");
|
||||
|
||||
console.log("Test 1: server package scripts launch supervisor-entrypoint");
|
||||
const serverPackage = JSON.parse(await readFile(serverPackagePath, "utf-8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
const startScript = serverPackage.scripts?.start ?? "";
|
||||
const devScript = serverPackage.scripts?.dev ?? "";
|
||||
const devTsxScript = serverPackage.scripts?.["dev:tsx"] ?? "";
|
||||
|
||||
assert(startScript.includes("dist/scripts/supervisor-entrypoint.js"), startScript);
|
||||
assertNoDirectWorkerLaunch("server start script", startScript);
|
||||
assert(devScript.includes("scripts/dev-runner.ts"), devScript);
|
||||
assertNoDirectWorkerLaunch("server dev script", devScript);
|
||||
assert(devTsxScript.includes("scripts/dev-runner.ts"), devTsxScript);
|
||||
assertNoDirectWorkerLaunch("server dev:tsx script", devTsxScript);
|
||||
console.log("✓ server package scripts enter supervisor\n");
|
||||
|
||||
console.log("Test 2: app e2e global setup launches supervisor-entrypoint in dev mode");
|
||||
const appGlobalSetup = await readFile(appGlobalSetupPath, "utf-8");
|
||||
assert(
|
||||
appGlobalSetup.includes('spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"]'),
|
||||
"app e2e setup should spawn supervisor-entrypoint.ts with --dev",
|
||||
);
|
||||
assertNoSpawnedWorkerEntrypoint("app e2e global setup", appGlobalSetup);
|
||||
console.log("✓ app e2e setup enters supervisor\n");
|
||||
|
||||
console.log("Test 3: server daemon e2e process launch enters supervisor");
|
||||
const serverConnectionOfferE2e = await readFile(serverConnectionOfferE2ePath, "utf-8");
|
||||
assert(
|
||||
serverConnectionOfferE2e.includes("scripts/supervisor-entrypoint.ts"),
|
||||
"server daemon e2e process launch should use supervisor-entrypoint.ts",
|
||||
);
|
||||
assertNoSpawnedWorkerEntrypoint("server daemon e2e process launch", serverConnectionOfferE2e);
|
||||
console.log("✓ server daemon e2e process launch enters supervisor\n");
|
||||
|
||||
console.log("Test 4: desktop runtime and Nix wrapper point at supervisor-entrypoint");
|
||||
const desktopRuntimePaths = await readFile(desktopRuntimePathsPath, "utf-8");
|
||||
assert(
|
||||
desktopRuntimePaths.includes('"dist", "scripts", "supervisor-entrypoint.js"'),
|
||||
"desktop packaged daemon runner should resolve dist/scripts/supervisor-entrypoint.js",
|
||||
);
|
||||
assert(
|
||||
desktopRuntimePaths.includes('"scripts", "supervisor-entrypoint.ts"'),
|
||||
"desktop dev daemon runner should resolve scripts/supervisor-entrypoint.ts",
|
||||
);
|
||||
assertNoDirectWorkerLaunch("desktop runtime paths", desktopRuntimePaths);
|
||||
|
||||
const nixPackage = await readFile(nixPackagePath, "utf-8");
|
||||
assert(
|
||||
nixPackage.includes("dist/scripts/supervisor-entrypoint.js"),
|
||||
"Nix paseo-server wrapper should use dist/scripts/supervisor-entrypoint.js",
|
||||
);
|
||||
assertNoDirectWorkerLaunch("Nix package wrapper", nixPackage);
|
||||
console.log("✓ desktop runtime and Nix wrapper enter supervisor\n");
|
||||
|
||||
console.log("=== Daemon launch supervision regression test passed ===");
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/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 { tryConnectToDaemon } from "../src/utils/client.ts";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
check: () => Promise<boolean> | boolean,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (await check()) return;
|
||||
if (Date.now() >= deadline) throw new Error(message);
|
||||
await sleep(pollIntervalMs);
|
||||
return poll();
|
||||
}
|
||||
|
||||
return poll();
|
||||
}
|
||||
|
||||
interface 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 canConnectToDaemon(host: string, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
async function poll(): Promise<boolean> {
|
||||
const client = await tryConnectToDaemon({ host, timeout: 500 }).catch(() => null);
|
||||
if (client) {
|
||||
await client.close().catch(() => undefined);
|
||||
return true;
|
||||
}
|
||||
if (Date.now() >= deadline) return false;
|
||||
await sleep(pollIntervalMs);
|
||||
return poll();
|
||||
}
|
||||
|
||||
return poll();
|
||||
}
|
||||
|
||||
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;
|
||||
let recentDaemonLogs = "";
|
||||
|
||||
try {
|
||||
console.log("Test 1: start unsupervised daemon worker directly");
|
||||
|
||||
daemonProcess = spawn(process.execPath, ["--import", "tsx", "../server/src/server/index.ts"], {
|
||||
cwd: cliRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...testEnv,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: host,
|
||||
PASEO_RELAY_ENABLED: "false",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
daemonProcess.stdout?.on("data", (chunk) => {
|
||||
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
|
||||
});
|
||||
daemonProcess.stderr?.on("data", (chunk) => {
|
||||
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
async () =>
|
||||
Boolean(daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) &&
|
||||
(await canConnectToDaemon(host, 1000)),
|
||||
120000,
|
||||
"daemon did not become running in time",
|
||||
);
|
||||
|
||||
assert(daemonProcess.pid, "unsupervised daemon process pid should exist");
|
||||
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}\nRecent daemon logs:\n${recentDaemonLogs}`,
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
async () => (await readPidLockPid(paseoHome)) === null,
|
||||
15000,
|
||||
"pid lock was not released 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 ===");
|
||||
@@ -11,14 +11,10 @@
|
||||
import { spawn } from "child_process";
|
||||
import { $ } from "zx";
|
||||
import { readdir, writeFile } from "fs/promises";
|
||||
import { join, dirname, delimiter } from "path";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// npm workspace scripts only add the local node_modules/.bin to PATH; hoisted
|
||||
// packages live in the root. Prepend it so `npx paseo` resolves locally.
|
||||
const rootNodeModulesBin = join(__dirname, "..", "..", "..", "node_modules", ".bin");
|
||||
const args = process.argv.slice(2);
|
||||
const testEnvDefaults = {
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
|
||||
@@ -197,7 +193,6 @@ async function runSingleTest(testFile: string): Promise<TestOutcome> {
|
||||
const proc = spawn("npx", ["tsx", testPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: [rootNodeModulesBin, process.env.PATH].filter(Boolean).join(delimiter),
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -43,22 +43,6 @@ describe("desktop-settings", () => {
|
||||
expect(persisted.settings).toEqual(DEFAULT_DESKTOP_SETTINGS);
|
||||
});
|
||||
|
||||
it("handles concurrent first-launch reads without racing the settings write", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
const store = createDesktopSettingsStore({ userDataPath });
|
||||
|
||||
const settings = await Promise.all(Array.from({ length: 20 }, () => store.get()));
|
||||
const persisted = JSON.parse(await readFile(settingsFilePath(userDataPath), "utf8")) as {
|
||||
settings: DesktopSettings;
|
||||
};
|
||||
const files = await readdir(userDataPath);
|
||||
|
||||
expect(settings).toEqual(Array.from({ length: 20 }, () => DEFAULT_DESKTOP_SETTINGS));
|
||||
expect(persisted.settings).toEqual(DEFAULT_DESKTOP_SETTINGS);
|
||||
expect(files).toEqual(["desktop-settings.json"]);
|
||||
});
|
||||
|
||||
it("coerces invalid persisted values back to safe defaults", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -199,19 +198,13 @@ export function createDesktopSettingsStore({
|
||||
}): DesktopSettingsStore {
|
||||
const filePath = path.join(userDataPath, DESKTOP_SETTINGS_FILENAME);
|
||||
let cachedDocument: PersistedDesktopSettingsDocument | null = null;
|
||||
let persistQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
async function persistDocument(document: PersistedDesktopSettingsDocument): Promise<void> {
|
||||
const write = async () => {
|
||||
await mkdir(userDataPath, { recursive: true });
|
||||
const tempFilePath = `${filePath}.tmp.${process.pid}.${randomUUID()}`;
|
||||
await writeFile(tempFilePath, `${JSON.stringify(document, null, 2)}\n`, "utf8");
|
||||
await rename(tempFilePath, filePath);
|
||||
cachedDocument = document;
|
||||
};
|
||||
const queued = persistQueue.then(write, write);
|
||||
persistQueue = queued.catch(() => undefined);
|
||||
await queued;
|
||||
await mkdir(userDataPath, { recursive: true });
|
||||
const tempFilePath = `${filePath}.tmp`;
|
||||
await writeFile(tempFilePath, `${JSON.stringify(document, null, 2)}\n`, "utf8");
|
||||
await rename(tempFilePath, filePath);
|
||||
cachedDocument = document;
|
||||
}
|
||||
|
||||
async function loadDocument(): Promise<PersistedDesktopSettingsDocument> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -29,12 +29,12 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "cross-env PASEO_NODE_ENV=development node --import tsx scripts/dev-runner.ts",
|
||||
"dev:tsx": "cross-env PASEO_NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' scripts/dev-runner.ts",
|
||||
"dev:tsx": "cross-env PASEO_NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
|
||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
|
||||
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs');\"",
|
||||
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
|
||||
"prepack": "npm run build",
|
||||
"start": "node dist/scripts/supervisor-entrypoint.js",
|
||||
"start": "node dist/server/server/index.js",
|
||||
"typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit",
|
||||
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
|
||||
"speech:models": "tsx scripts/list-speech-models.ts",
|
||||
@@ -58,8 +58,8 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.69",
|
||||
"@getpaseo/relay": "0.1.69",
|
||||
"@getpaseo/highlight": "0.1.67",
|
||||
"@getpaseo/relay": "0.1.67",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
|
||||
@@ -26,10 +26,7 @@ const supervisorArgs = [
|
||||
|
||||
const supervisor = spawn(process.execPath, supervisorArgs, {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_LOG_FORMAT: process.env.PASEO_LOG_FORMAT ?? "pretty",
|
||||
},
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
function exitCodeForSignal(signal: NodeJS.Signals): number {
|
||||
|
||||
@@ -15,17 +15,6 @@ describe("supervision parity", () => {
|
||||
expect(daemonRunnerCalls + devRunnerCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("supervisor worker implementation is not server/index", () => {
|
||||
const daemonRunner = readFileSync(
|
||||
new URL("./supervisor-entrypoint.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(daemonRunner).toContain("daemon-worker");
|
||||
expect(daemonRunner).not.toContain("server/server/index.js");
|
||||
expect(daemonRunner).not.toContain("src/server/index.ts");
|
||||
});
|
||||
|
||||
test("dev runner waits asynchronously for supervisor shutdown", () => {
|
||||
const devRunner = readFileSync(new URL("./dev-runner.ts", import.meta.url), "utf8");
|
||||
|
||||
|
||||
@@ -8,14 +8,9 @@ import {
|
||||
updatePidLock,
|
||||
} from "../src/server/pid-lock.js";
|
||||
import { resolvePaseoHome } from "../src/server/paseo-home.js";
|
||||
import { loadPersistedConfig } from "../src/server/persisted-config.js";
|
||||
import { runSupervisor } from "./supervisor.js";
|
||||
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
|
||||
|
||||
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const DEFAULT_LOG_ROTATE_SIZE = "10m";
|
||||
const DEFAULT_LOG_ROTATE_MAX_FILES = 2;
|
||||
|
||||
interface DaemonRunnerConfig {
|
||||
devMode: boolean;
|
||||
workerArgs: string[];
|
||||
@@ -38,10 +33,10 @@ function parseConfig(argv: string[]): DaemonRunnerConfig {
|
||||
|
||||
function resolveWorkerEntry(): string {
|
||||
const candidates = [
|
||||
fileURLToPath(new URL("../server/server/daemon-worker.js", import.meta.url)),
|
||||
fileURLToPath(new URL("../dist/server/server/daemon-worker.js", import.meta.url)),
|
||||
fileURLToPath(new URL("../src/server/daemon-worker.ts", import.meta.url)),
|
||||
fileURLToPath(new URL("../../src/server/daemon-worker.ts", import.meta.url)),
|
||||
fileURLToPath(new URL("../server/server/index.js", import.meta.url)),
|
||||
fileURLToPath(new URL("../dist/server/server/index.js", import.meta.url)),
|
||||
fileURLToPath(new URL("../src/server/index.ts", import.meta.url)),
|
||||
fileURLToPath(new URL("../../src/server/index.ts", import.meta.url)),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
@@ -54,7 +49,7 @@ function resolveWorkerEntry(): string {
|
||||
}
|
||||
|
||||
function resolveDevWorkerEntry(): string {
|
||||
const candidate = fileURLToPath(new URL("../src/server/daemon-worker.ts", import.meta.url));
|
||||
const candidate = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
|
||||
if (!existsSync(candidate)) {
|
||||
throw new Error(`Dev worker entry not found: ${candidate}`);
|
||||
}
|
||||
@@ -77,33 +72,11 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str
|
||||
return existsSync(runnerPath) ? runnerPath : null;
|
||||
}
|
||||
|
||||
function resolveSupervisorLogFile(
|
||||
paseoHome: string,
|
||||
persistedConfig: ReturnType<typeof loadPersistedConfig>,
|
||||
) {
|
||||
const configuredFile = persistedConfig.log?.file;
|
||||
const configuredPath = configuredFile?.path;
|
||||
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
|
||||
if (configuredPath) {
|
||||
logPath = path.isAbsolute(configuredPath)
|
||||
? configuredPath
|
||||
: path.resolve(paseoHome, configuredPath);
|
||||
}
|
||||
|
||||
return {
|
||||
path: logPath,
|
||||
rotate: {
|
||||
maxSize: configuredFile?.rotate?.maxSize ?? DEFAULT_LOG_ROTATE_SIZE,
|
||||
maxFiles: configuredFile?.rotate?.maxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = parseConfig(process.argv.slice(2));
|
||||
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
|
||||
const workerExecArgv = resolveWorkerExecArgv(workerEntry);
|
||||
const workerEnv: NodeJS.ProcessEnv = { ...process.env };
|
||||
const workerEnv: NodeJS.ProcessEnv = { ...process.env, PASEO_SUPERVISED: "1" };
|
||||
const packagedNodeEntrypointRunner =
|
||||
process.env.ELECTRON_RUN_AS_NODE === "1"
|
||||
? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url))
|
||||
@@ -112,8 +85,6 @@ async function main(): Promise<void> {
|
||||
applySherpaLoaderEnv(workerEnv);
|
||||
|
||||
const paseoHome = resolvePaseoHome(workerEnv);
|
||||
const persistedConfig = loadPersistedConfig(paseoHome);
|
||||
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig);
|
||||
|
||||
try {
|
||||
await acquirePidLock(paseoHome, null, {
|
||||
@@ -141,7 +112,9 @@ async function main(): Promise<void> {
|
||||
|
||||
runSupervisor({
|
||||
name: "DaemonRunner",
|
||||
startupMessage: "Starting daemon worker (IPC restart and crash restart enabled)",
|
||||
startupMessage: config.devMode
|
||||
? "Starting daemon worker (dev mode, crash restarts enabled)"
|
||||
: "Starting daemon worker (IPC restart enabled)",
|
||||
resolveWorkerEntry: () => workerEntry,
|
||||
workerArgs: config.workerArgs,
|
||||
workerEnv,
|
||||
@@ -161,8 +134,7 @@ async function main(): Promise<void> {
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
restartOnCrash: true,
|
||||
logFile: supervisorLogFile,
|
||||
restartOnCrash: config.devMode,
|
||||
onWorkerReady: async ({ listen }) => {
|
||||
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
|
||||
},
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
|
||||
|
||||
async function runSupervisorFixture(options: {
|
||||
workerSource: string;
|
||||
restartOnCrash?: boolean;
|
||||
}): Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
log: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), "paseo-supervisor-log-"));
|
||||
const logPath = path.join(tempDir, "daemon.log");
|
||||
const workerPath = path.join(tempDir, "worker.mjs");
|
||||
const runnerPath = path.join(tempDir, "runner.mjs");
|
||||
|
||||
await writeFile(workerPath, options.workerSource);
|
||||
await writeFile(
|
||||
runnerPath,
|
||||
`
|
||||
import { runSupervisor } from ${JSON.stringify(pathToFileURL(supervisorPath).href)};
|
||||
|
||||
runSupervisor({
|
||||
name: "TestSupervisor",
|
||||
startupMessage: "starting fixture",
|
||||
resolveWorkerEntry: () => ${JSON.stringify(workerPath)},
|
||||
workerArgs: [],
|
||||
workerEnv: process.env,
|
||||
workerExecArgv: [],
|
||||
restartOnCrash: ${JSON.stringify(options.restartOnCrash ?? false)},
|
||||
logFile: {
|
||||
path: ${JSON.stringify(logPath)},
|
||||
rotate: { maxSize: "1m", maxFiles: 2 },
|
||||
},
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
const { code, signal } = await new Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
}>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("supervisor fixture timed out"));
|
||||
}, 10000);
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (exitCode, exitSignal) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code: exitCode, signal: exitSignal });
|
||||
});
|
||||
});
|
||||
|
||||
const log = await readFile(logPath, "utf8");
|
||||
return { code, signal, log, stdout, stderr };
|
||||
}
|
||||
|
||||
describe("supervisor durable logging", () => {
|
||||
test("writes supervised worker stdout and stderr to daemon.log", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
workerSource: `
|
||||
process.stdout.write('{"level":30,"msg":"worker-json-stdout"}\\n');
|
||||
process.stderr.write('{"level":50,"msg":"worker-json-stderr"}\\n');
|
||||
process.exit(0);
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain('"worker-json-stdout"');
|
||||
expect(result.log).toContain('"worker-json-stderr"');
|
||||
expect(result.stdout).toContain('"worker-json-stdout"');
|
||||
expect(result.stderr).toContain('"worker-json-stderr"');
|
||||
});
|
||||
|
||||
test("preserves raw non-JSON stdout and stderr lines", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
workerSource: `
|
||||
process.stdout.write('raw stdout line\\n');
|
||||
process.stderr.write('raw stderr line\\n');
|
||||
process.exit(0);
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.log).toContain("raw stdout line\n");
|
||||
expect(result.log).toContain("raw stderr line\n");
|
||||
});
|
||||
|
||||
test("logs worker signal exits even when the worker cannot log", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
workerSource: `
|
||||
process.kill(process.pid, "SIGKILL");
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain('"msg":"Worker exited"');
|
||||
expect(result.log).toContain('"signal":"SIGKILL"');
|
||||
expect(result.log).toContain("Supervisor exiting");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,4 @@
|
||||
import { fork, spawn, type ChildProcess } from "child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createStream as createRotatingFileStream } from "rotating-file-stream";
|
||||
|
||||
interface SupervisorLogFileOptions {
|
||||
path: string;
|
||||
rotate: {
|
||||
maxSize: string;
|
||||
maxFiles: number;
|
||||
};
|
||||
}
|
||||
|
||||
type WorkerLifecycleMessage =
|
||||
| {
|
||||
@@ -39,7 +28,6 @@ interface SupervisorOptions {
|
||||
onWorkerReady?: (message: { listen: string }) => Promise<void> | void;
|
||||
restartOnCrash?: boolean;
|
||||
onSupervisorExit?: () => Promise<void> | void;
|
||||
logFile?: SupervisorLogFileOptions;
|
||||
}
|
||||
|
||||
function describeExit(code: number | null, signal: NodeJS.Signals | null): string {
|
||||
@@ -71,31 +59,6 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function toRotatingFileStreamSize(size: string): string {
|
||||
const trimmed = size.trim();
|
||||
const match = trimmed.match(/^(\d+)\s*([bBkKmMgG])?$/);
|
||||
if (!match) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const value = match[1];
|
||||
const unit = (match[2] ?? "M").toUpperCase();
|
||||
return `${value}${unit}`;
|
||||
}
|
||||
|
||||
function createSupervisorLogStream(options: SupervisorLogFileOptions | undefined) {
|
||||
if (!options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(options.path), { recursive: true });
|
||||
return createRotatingFileStream(path.basename(options.path), {
|
||||
path: path.dirname(options.path),
|
||||
size: toRotatingFileStreamSize(options.rotate.maxSize),
|
||||
maxFiles: options.rotate.maxFiles,
|
||||
});
|
||||
}
|
||||
|
||||
export function runSupervisor(options: SupervisorOptions): void {
|
||||
const restartOnCrash = options.restartOnCrash ?? false;
|
||||
const workerArgs = options.workerArgs ?? process.argv.slice(2);
|
||||
@@ -107,39 +70,11 @@ export function runSupervisor(options: SupervisorOptions): void {
|
||||
let restarting = false;
|
||||
let shuttingDown = false;
|
||||
let exiting = false;
|
||||
const logStream = createSupervisorLogStream(options.logFile);
|
||||
|
||||
const writeDurableChunk = (chunk: string | Buffer): void => {
|
||||
logStream?.write(chunk);
|
||||
};
|
||||
|
||||
const writeLifecycleLog = (message: string, fields: Record<string, unknown> = {}): void => {
|
||||
writeDurableChunk(
|
||||
`${JSON.stringify({
|
||||
level: "info",
|
||||
time: new Date().toISOString(),
|
||||
pid: process.pid,
|
||||
name: options.name,
|
||||
msg: message,
|
||||
...fields,
|
||||
})}\n`,
|
||||
);
|
||||
};
|
||||
|
||||
const log = (message: string): void => {
|
||||
process.stderr.write(`[${options.name}] ${message}\n`);
|
||||
writeLifecycleLog(message);
|
||||
};
|
||||
|
||||
const closeLogStream = (): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
if (!logStream) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
logStream.end(resolve);
|
||||
});
|
||||
|
||||
const exitSupervisor = (code: number): void => {
|
||||
if (exiting) {
|
||||
return;
|
||||
@@ -150,7 +85,6 @@ export function runSupervisor(options: SupervisorOptions): void {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log(`Supervisor exit cleanup failed: ${message}`);
|
||||
})
|
||||
.then(closeLogStream)
|
||||
.finally(() => {
|
||||
process.exit(code);
|
||||
});
|
||||
@@ -169,30 +103,19 @@ export function runSupervisor(options: SupervisorOptions): void {
|
||||
}
|
||||
|
||||
const spawnSpec = resolveWorkerSpawnSpec?.(workerEntry) ?? null;
|
||||
writeLifecycleLog("Spawning worker", { workerEntry });
|
||||
if (spawnSpec) {
|
||||
child = spawn(spawnSpec.command, spawnSpec.args, {
|
||||
stdio: ["inherit", "pipe", "pipe", "ipc"],
|
||||
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
||||
env: spawnSpec.env ?? workerEnv,
|
||||
});
|
||||
} else {
|
||||
child = fork(workerEntry, workerArgs, {
|
||||
stdio: ["inherit", "pipe", "pipe", "ipc"],
|
||||
stdio: "inherit",
|
||||
env: workerEnv,
|
||||
execArgv: workerExecArgv,
|
||||
});
|
||||
}
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
process.stdout.write(chunk);
|
||||
writeDurableChunk(chunk);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
process.stderr.write(chunk);
|
||||
writeDurableChunk(chunk);
|
||||
});
|
||||
|
||||
child.on("message", (msg: unknown) => {
|
||||
const lifecycleMessage = parseLifecycleMessage(msg);
|
||||
if (!lifecycleMessage) {
|
||||
@@ -200,7 +123,6 @@ export function runSupervisor(options: SupervisorOptions): void {
|
||||
}
|
||||
|
||||
if (lifecycleMessage.type === "paseo:ready") {
|
||||
writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen });
|
||||
Promise.resolve(options.onWorkerReady?.({ listen: lifecycleMessage.listen })).catch(
|
||||
(error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -211,21 +133,15 @@ export function runSupervisor(options: SupervisorOptions): void {
|
||||
}
|
||||
|
||||
if (lifecycleMessage.type === "paseo:shutdown") {
|
||||
writeLifecycleLog("Worker requested shutdown");
|
||||
requestShutdown("Shutdown requested by worker");
|
||||
return;
|
||||
}
|
||||
|
||||
writeLifecycleLog(
|
||||
"Worker requested restart",
|
||||
lifecycleMessage.reason ? { reason: lifecycleMessage.reason } : {},
|
||||
);
|
||||
requestRestart("Restart requested by worker");
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
child.on("exit", (code, signal) => {
|
||||
const exitDescriptor = describeExit(code, signal);
|
||||
writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor });
|
||||
|
||||
if (shuttingDown) {
|
||||
log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`);
|
||||
@@ -235,21 +151,17 @@ export function runSupervisor(options: SupervisorOptions): void {
|
||||
|
||||
const crashed =
|
||||
restartOnCrash &&
|
||||
((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM"));
|
||||
((code !== 0 && code !== null) || (signal !== null && signal === "SIGKILL"));
|
||||
|
||||
if (restarting || crashed) {
|
||||
restarting = false;
|
||||
log(
|
||||
crashed
|
||||
? `Worker crashed (${exitDescriptor}). Restarting worker...`
|
||||
: `Worker exited (${exitDescriptor}). Restarting worker...`,
|
||||
);
|
||||
log(`Worker exited (${exitDescriptor}). Restarting worker...`);
|
||||
spawnWorker();
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Worker exited (${exitDescriptor}). Supervisor exiting.`);
|
||||
exitSupervisor(typeof code === "number" ? code : 1);
|
||||
exitSupervisor(typeof code === "number" ? code : 0);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -258,7 +170,6 @@ export function runSupervisor(options: SupervisorOptions): void {
|
||||
return;
|
||||
}
|
||||
restarting = true;
|
||||
writeLifecycleLog("Restart requested", { reason });
|
||||
log(`${reason}. Stopping worker for restart...`);
|
||||
child.kill("SIGTERM");
|
||||
};
|
||||
@@ -269,7 +180,6 @@ export function runSupervisor(options: SupervisorOptions): void {
|
||||
}
|
||||
shuttingDown = true;
|
||||
restarting = false;
|
||||
writeLifecycleLog("Supervisor shutdown requested", { reason });
|
||||
log(`${reason}. Stopping worker...`);
|
||||
if (!child) {
|
||||
exitSupervisor(0);
|
||||
@@ -286,6 +196,5 @@ export function runSupervisor(options: SupervisorOptions): void {
|
||||
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
|
||||
|
||||
process.stdout.write(`[${options.name}] ${options.startupMessage}\n`);
|
||||
writeLifecycleLog(options.startupMessage);
|
||||
spawnWorker();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
||||
import { encodeFileTransferFrame, FileTransferOpcode } from "../shared/binary-frames/index.js";
|
||||
import {
|
||||
@@ -95,22 +94,6 @@ function wrapSessionMessage(message: unknown): string {
|
||||
});
|
||||
}
|
||||
|
||||
function assertStr(data: string | Uint8Array | ArrayBuffer | undefined): string {
|
||||
if (typeof data !== "string") throw new Error("Expected string frame");
|
||||
return data;
|
||||
}
|
||||
|
||||
function parseSentFrame(
|
||||
data: string | Uint8Array | ArrayBuffer | undefined,
|
||||
): Record<string, unknown> {
|
||||
return z
|
||||
.object({
|
||||
type: z.literal("session"),
|
||||
message: z.record(z.unknown()),
|
||||
})
|
||||
.parse(JSON.parse(assertStr(data))).message;
|
||||
}
|
||||
|
||||
const clients: DaemonClient[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -140,7 +123,10 @@ test("dedupes in-flight checkout status requests per agentId", async () => {
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: { type: "checkout_status_request"; cwd: string; requestId: string };
|
||||
};
|
||||
|
||||
const response = {
|
||||
type: "session",
|
||||
@@ -149,7 +135,7 @@ test("dedupes in-flight checkout status requests per agentId", async () => {
|
||||
payload: {
|
||||
cwd: "/tmp/project",
|
||||
error: null,
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
isGit: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
repoRoot: null,
|
||||
@@ -169,12 +155,12 @@ test("dedupes in-flight checkout status requests per agentId", async () => {
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
expect(r1).toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
isGit: false,
|
||||
});
|
||||
expect(r2).toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
@@ -182,21 +168,24 @@ test("dedupes in-flight checkout status requests per agentId", async () => {
|
||||
const p3 = client.getCheckoutStatus("/tmp/project");
|
||||
expect(mock.sent).toHaveLength(2);
|
||||
|
||||
const request2 = parseSentFrame(mock.sent[1]);
|
||||
const request2 = JSON.parse(mock.sent[1]) as {
|
||||
type: "session";
|
||||
message: { type: "checkout_status_request"; cwd: string; requestId: string };
|
||||
};
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
...response,
|
||||
message: {
|
||||
...response.message,
|
||||
payload: { ...response.message.payload, requestId: request2.requestId },
|
||||
payload: { ...response.message.payload, requestId: request2.message.requestId },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(p3).resolves.toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
requestId: request2.requestId,
|
||||
requestId: request2.message.requestId,
|
||||
isGit: false,
|
||||
});
|
||||
});
|
||||
@@ -245,7 +234,7 @@ test("advertises reasoning_merge_enum in hello", async () => {
|
||||
await connectPromise;
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
||||
expect(JSON.parse(mock.sent[0] as string)).toEqual({
|
||||
type: "hello",
|
||||
clientId: "clsk_unit_test",
|
||||
clientType: "cli",
|
||||
@@ -300,7 +289,7 @@ test("listDirectory sends a list file explorer request and returns directory ent
|
||||
|
||||
const responsePromise = client.listDirectory("/tmp/project", "src", "req-list");
|
||||
|
||||
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
||||
expect(JSON.parse(String(mock.sent[0]))).toEqual({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "file_explorer_request",
|
||||
@@ -370,7 +359,7 @@ test("readFile hides legacy base64 behind bytes", async () => {
|
||||
|
||||
const responsePromise = client.readFile("/tmp/project", "logo.png", "req-file");
|
||||
|
||||
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
||||
expect(JSON.parse(String(mock.sent[0]))).toEqual({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "file_explorer_request",
|
||||
@@ -435,7 +424,7 @@ test("readFile resolves from binary file frames when the daemon supports them",
|
||||
|
||||
const responsePromise = client.readFile("/tmp/project", "logo.png", "req-binary");
|
||||
|
||||
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
||||
expect(JSON.parse(String(mock.sent[0]))).toEqual({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "file_explorer_request",
|
||||
@@ -586,8 +575,15 @@ test("sends create_agent_request with string workspace ids", async () => {
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toEqual(
|
||||
const request = JSON.parse(String(mock.sent[0])) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "create_agent_request";
|
||||
requestId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "create_agent_request",
|
||||
workspaceId: "ws-feature-a",
|
||||
@@ -599,7 +595,7 @@ test("sends create_agent_request with string workspace ids", async () => {
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_create_failed",
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
error: "compat test sentinel",
|
||||
},
|
||||
}),
|
||||
@@ -643,8 +639,15 @@ test("sends structured attachments with create_agent_request", async () => {
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.attachments).toEqual([
|
||||
const request = JSON.parse(String(mock.sent[0])) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "create_agent_request";
|
||||
requestId: string;
|
||||
attachments: Array<{ type: string; mimeType: string; number: number }>;
|
||||
};
|
||||
};
|
||||
expect(request.message.attachments).toEqual([
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
@@ -661,7 +664,7 @@ test("sends structured attachments with create_agent_request", async () => {
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_create_failed",
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
error: "attachment test sentinel",
|
||||
},
|
||||
}),
|
||||
@@ -701,8 +704,21 @@ test("sends worktree base-ref fields in create_agent_request git options", async
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.git).toEqual({
|
||||
const request = JSON.parse(String(mock.sent[0])) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "create_agent_request";
|
||||
requestId: string;
|
||||
git: {
|
||||
createWorktree: boolean;
|
||||
worktreeSlug: string;
|
||||
refName: string;
|
||||
action: string;
|
||||
githubPrNumber: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(request.message.git).toEqual({
|
||||
createWorktree: true,
|
||||
worktreeSlug: "review-pr-123",
|
||||
refName: "feature/worktree-base-ref",
|
||||
@@ -715,7 +731,7 @@ test("sends worktree base-ref fields in create_agent_request git options", async
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_create_failed",
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
error: "git ref fields sentinel",
|
||||
},
|
||||
}),
|
||||
@@ -751,7 +767,7 @@ test("omitting create_agent_request worktree base-ref fields preserves legacy wi
|
||||
},
|
||||
});
|
||||
|
||||
expect(assertStr(mock.sent[0])).toBe(
|
||||
expect(String(mock.sent[0])).toBe(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
@@ -818,11 +834,17 @@ test("sends structured first-agent context attachments with create_paseo_worktre
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
const firstAgentContext = z
|
||||
.object({ attachments: z.array(z.unknown()) })
|
||||
.parse(request.firstAgentContext);
|
||||
expect(firstAgentContext.attachments).toEqual([
|
||||
const request = JSON.parse(String(mock.sent[0])) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "create_paseo_worktree_request";
|
||||
requestId: string;
|
||||
firstAgentContext: {
|
||||
attachments: Array<{ type: string; mimeType: string; number: number }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(request.message.firstAgentContext.attachments).toEqual([
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
@@ -836,7 +858,7 @@ test("sends structured first-agent context attachments with create_paseo_worktre
|
||||
wrapSessionMessage({
|
||||
type: "create_paseo_worktree_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
workspace: null,
|
||||
error: "worktree attachment sentinel",
|
||||
setupTerminalId: null,
|
||||
@@ -845,7 +867,7 @@ test("sends structured first-agent context attachments with create_paseo_worktre
|
||||
);
|
||||
|
||||
await expect(createPromise).resolves.toEqual({
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
workspace: null,
|
||||
error: "worktree attachment sentinel",
|
||||
setupTerminalId: null,
|
||||
@@ -881,8 +903,19 @@ test("sends worktree base-ref fields in create_paseo_worktree_request", async ()
|
||||
);
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toEqual({
|
||||
const request = JSON.parse(String(mock.sent[0])) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "create_paseo_worktree_request";
|
||||
requestId: string;
|
||||
cwd: string;
|
||||
worktreeSlug: string;
|
||||
refName: string;
|
||||
action: string;
|
||||
githubPrNumber: number;
|
||||
};
|
||||
};
|
||||
expect(request.message).toEqual({
|
||||
type: "create_paseo_worktree_request",
|
||||
cwd: "/tmp/project",
|
||||
worktreeSlug: "review-pr-123",
|
||||
@@ -896,7 +929,7 @@ test("sends worktree base-ref fields in create_paseo_worktree_request", async ()
|
||||
wrapSessionMessage({
|
||||
type: "create_paseo_worktree_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
workspace: null,
|
||||
error: "worktree ref fields sentinel",
|
||||
setupTerminalId: null,
|
||||
@@ -905,7 +938,7 @@ test("sends worktree base-ref fields in create_paseo_worktree_request", async ()
|
||||
);
|
||||
|
||||
await expect(createPromise).resolves.toEqual({
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
workspace: null,
|
||||
error: "worktree ref fields sentinel",
|
||||
setupTerminalId: null,
|
||||
@@ -937,7 +970,7 @@ test("omitting create_paseo_worktree_request worktree base-ref fields preserves
|
||||
"req-worktree-legacy",
|
||||
);
|
||||
|
||||
expect(assertStr(mock.sent[0])).toBe(
|
||||
expect(String(mock.sent[0])).toBe(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
@@ -998,8 +1031,14 @@ test("sends explicit shutdown_server_request via shutdownServer", async () => {
|
||||
const promise = lifecycleClient.shutdownServer("req-shutdown-1");
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toEqual({
|
||||
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",
|
||||
});
|
||||
@@ -1042,8 +1081,15 @@ test("restartServer remains restart-only and sends restart_server_request", asyn
|
||||
const promise = client.restartServer("settings_update", "req-restart-1");
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toEqual({
|
||||
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",
|
||||
@@ -1096,9 +1142,7 @@ test("transitions out of connecting when connect timeout elapses", async () => {
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
if (result.error instanceof Error) {
|
||||
expect(result.error.message).toContain("Connection timed out");
|
||||
}
|
||||
expect((result.error as Error).message).toContain("Connection timed out");
|
||||
}
|
||||
expect(client.getConnectionState().status).toBe("disconnected");
|
||||
} finally {
|
||||
@@ -1190,14 +1234,12 @@ test("logs configured runtime generation in connection transition events", async
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const transitionPayloads = logger.debug.mock.calls.filter(
|
||||
([, message]) => message === "DaemonClientTransition",
|
||||
);
|
||||
const transitionPayloads = logger.debug.mock.calls
|
||||
.filter(([, message]) => message === "DaemonClientTransition")
|
||||
.map(([payload]) => payload as { generation?: number | null });
|
||||
expect(transitionPayloads.length).toBeGreaterThan(0);
|
||||
for (const [payload] of transitionPayloads) {
|
||||
expect(
|
||||
z.object({ generation: z.number().nullable().optional() }).parse(payload).generation,
|
||||
).toBe(7);
|
||||
for (const payload of transitionPayloads) {
|
||||
expect(payload.generation).toBe(7);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1225,11 +1267,20 @@ test("subscribes to checkout diff updates via RPC handshake", async () => {
|
||||
);
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.type).toBe("subscribe_checkout_diff_request");
|
||||
expect(request.subscriptionId).toBe("checkout-sub-1");
|
||||
expect(request.cwd).toBe("/tmp/project");
|
||||
expect(request.compare).toEqual({ mode: "uncommitted" });
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "subscribe_checkout_diff_request";
|
||||
subscriptionId: string;
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string };
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("subscribe_checkout_diff_request");
|
||||
expect(request.message.subscriptionId).toBe("checkout-sub-1");
|
||||
expect(request.message.cwd).toBe("/tmp/project");
|
||||
expect(request.message.compare).toEqual({ mode: "uncommitted" });
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
@@ -1241,7 +1292,7 @@ test("subscribes to checkout diff updates via RPC handshake", async () => {
|
||||
cwd: "/tmp/project",
|
||||
files: [],
|
||||
error: null,
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -1252,7 +1303,7 @@ test("subscribes to checkout diff updates via RPC handshake", async () => {
|
||||
cwd: "/tmp/project",
|
||||
files: [],
|
||||
error: null,
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1276,10 +1327,19 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => {
|
||||
const promise = client.getCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" });
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const subscribeRequest = parseSentFrame(mock.sent[0]);
|
||||
expect(subscribeRequest.type).toBe("subscribe_checkout_diff_request");
|
||||
expect(subscribeRequest.cwd).toBe("/tmp/project");
|
||||
expect(subscribeRequest.compare).toEqual({
|
||||
const subscribeRequest = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "subscribe_checkout_diff_request";
|
||||
subscriptionId: string;
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string };
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(subscribeRequest.message.type).toBe("subscribe_checkout_diff_request");
|
||||
expect(subscribeRequest.message.cwd).toBe("/tmp/project");
|
||||
expect(subscribeRequest.message.compare).toEqual({
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
});
|
||||
@@ -1290,11 +1350,11 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => {
|
||||
message: {
|
||||
type: "subscribe_checkout_diff_response",
|
||||
payload: {
|
||||
subscriptionId: subscribeRequest.subscriptionId,
|
||||
subscriptionId: subscribeRequest.message.subscriptionId,
|
||||
cwd: "/tmp/project",
|
||||
files: [],
|
||||
error: null,
|
||||
requestId: subscribeRequest.requestId,
|
||||
requestId: subscribeRequest.message.requestId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -1304,13 +1364,19 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => {
|
||||
cwd: "/tmp/project",
|
||||
files: [],
|
||||
error: null,
|
||||
requestId: subscribeRequest.requestId,
|
||||
requestId: subscribeRequest.message.requestId,
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(2);
|
||||
const unsubscribeRequest = parseSentFrame(mock.sent[1]);
|
||||
expect(unsubscribeRequest.type).toBe("unsubscribe_checkout_diff_request");
|
||||
expect(unsubscribeRequest.subscriptionId).toBe(subscribeRequest.subscriptionId);
|
||||
const unsubscribeRequest = JSON.parse(mock.sent[1]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "unsubscribe_checkout_diff_request";
|
||||
subscriptionId: string;
|
||||
};
|
||||
};
|
||||
expect(unsubscribeRequest.message.type).toBe("unsubscribe_checkout_diff_request");
|
||||
expect(unsubscribeRequest.message.subscriptionId).toBe(subscribeRequest.message.subscriptionId);
|
||||
});
|
||||
|
||||
test("requests branch suggestions via RPC", async () => {
|
||||
@@ -1336,12 +1402,21 @@ test("requests branch suggestions via RPC", async () => {
|
||||
);
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.type).toBe("branch_suggestions_request");
|
||||
expect(request.cwd).toBe("/tmp/project");
|
||||
expect(request.query).toBe("mai");
|
||||
expect(request.limit).toBe(5);
|
||||
expect(request.requestId).toBe("req-branches");
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "branch_suggestions_request";
|
||||
cwd: string;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("branch_suggestions_request");
|
||||
expect(request.message.cwd).toBe("/tmp/project");
|
||||
expect(request.message.query).toBe("mai");
|
||||
expect(request.message.limit).toBe(5);
|
||||
expect(request.message.requestId).toBe("req-branches");
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
@@ -1383,8 +1458,11 @@ test("reads project config via correlated RPC", async () => {
|
||||
const promise = client.readProjectConfig("/repo/app", "read-project-config-1");
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toEqual({
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: { type: "read_project_config_request"; requestId: string; repoRoot: string };
|
||||
};
|
||||
expect(request.message).toEqual({
|
||||
type: "read_project_config_request",
|
||||
requestId: "read-project-config-1",
|
||||
repoRoot: "/repo/app",
|
||||
@@ -1436,8 +1514,17 @@ test("writes project config via correlated RPC and returns inline failures", asy
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toEqual({
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "write_project_config_request";
|
||||
requestId: string;
|
||||
repoRoot: string;
|
||||
config: unknown;
|
||||
expectedRevision: unknown;
|
||||
};
|
||||
};
|
||||
expect(request.message).toEqual({
|
||||
type: "write_project_config_request",
|
||||
requestId: "write-project-config-1",
|
||||
repoRoot: "/repo/app",
|
||||
@@ -1500,14 +1587,25 @@ test("requests directory suggestions via RPC", async () => {
|
||||
);
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.type).toBe("directory_suggestions_request");
|
||||
expect(request.query).toBe("proj");
|
||||
expect(request.cwd).toBe("/tmp/project");
|
||||
expect(request.includeFiles).toBe(true);
|
||||
expect(request.includeDirectories).toBe(true);
|
||||
expect(request.limit).toBe(10);
|
||||
expect(request.requestId).toBe("req-directories");
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "directory_suggestions_request";
|
||||
query: string;
|
||||
cwd?: string;
|
||||
includeFiles?: boolean;
|
||||
includeDirectories?: boolean;
|
||||
limit?: number;
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("directory_suggestions_request");
|
||||
expect(request.message.query).toBe("proj");
|
||||
expect(request.message.cwd).toBe("/tmp/project");
|
||||
expect(request.message.includeFiles).toBe(true);
|
||||
expect(request.message.includeDirectories).toBe(true);
|
||||
expect(request.message.limit).toBe(10);
|
||||
expect(request.message.requestId).toBe("req-directories");
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
@@ -1556,12 +1654,21 @@ test("requests checkout merge from base via RPC", async () => {
|
||||
);
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.type).toBe("checkout_merge_from_base_request");
|
||||
expect(request.cwd).toBe("/tmp/project");
|
||||
expect(request.baseRef).toBe("main");
|
||||
expect(request.requireCleanTarget).toBe(true);
|
||||
expect(request.requestId).toBe("req-merge-from-base");
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "checkout_merge_from_base_request";
|
||||
cwd: string;
|
||||
baseRef?: string;
|
||||
requireCleanTarget?: boolean;
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("checkout_merge_from_base_request");
|
||||
expect(request.message.cwd).toBe("/tmp/project");
|
||||
expect(request.message.baseRef).toBe("main");
|
||||
expect(request.message.requireCleanTarget).toBe(true);
|
||||
expect(request.message.requestId).toBe("req-merge-from-base");
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
@@ -1606,10 +1713,17 @@ test("requests checkout pull via RPC", async () => {
|
||||
const promise = client.checkoutPull("/tmp/project", "req-pull");
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.type).toBe("checkout_pull_request");
|
||||
expect(request.cwd).toBe("/tmp/project");
|
||||
expect(request.requestId).toBe("req-pull");
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "checkout_pull_request";
|
||||
cwd: string;
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("checkout_pull_request");
|
||||
expect(request.message.cwd).toBe("/tmp/project");
|
||||
expect(request.message.requestId).toBe("req-pull");
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
@@ -1663,13 +1777,22 @@ test("resubscribes checkout diff streams after reconnect", async () => {
|
||||
await connectPromise;
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.type).toBe("subscribe_checkout_diff_request");
|
||||
expect(request.subscriptionId).toBe("checkout-sub-1");
|
||||
expect(request.cwd).toBe("/tmp/project");
|
||||
expect(request.compare).toEqual({ mode: "base", baseRef: "main" });
|
||||
expect(typeof request.requestId).toBe("string");
|
||||
expect(z.string().parse(request.requestId).length).toBeGreaterThan(0);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "subscribe_checkout_diff_request";
|
||||
subscriptionId: string;
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string };
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("subscribe_checkout_diff_request");
|
||||
expect(request.message.subscriptionId).toBe("checkout-sub-1");
|
||||
expect(request.message.cwd).toBe("/tmp/project");
|
||||
expect(request.message.compare).toEqual({ mode: "base", baseRef: "main" });
|
||||
expect(typeof request.message.requestId).toBe("string");
|
||||
expect(request.message.requestId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("fetches agents via RPC with filters, sort, and pagination", async () => {
|
||||
@@ -1700,14 +1823,27 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => {
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.type).toBe("fetch_agents_request");
|
||||
expect(request.sort).toEqual([
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "fetch_agents_request";
|
||||
requestId: string;
|
||||
filter?: { labels?: Record<string, string> };
|
||||
sort?: Array<{
|
||||
key: "status_priority" | "created_at" | "updated_at" | "title";
|
||||
direction: "asc" | "desc";
|
||||
}>;
|
||||
page?: { limit: number; cursor?: string };
|
||||
subscribe?: { subscriptionId?: string };
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("fetch_agents_request");
|
||||
expect(request.message.sort).toEqual([
|
||||
{ key: "status_priority", direction: "asc" },
|
||||
{ key: "created_at", direction: "desc" },
|
||||
]);
|
||||
expect(request.page).toEqual({ limit: 25, cursor: "cursor-1" });
|
||||
expect(request.subscribe).toEqual({ subscriptionId: "sub-1" });
|
||||
expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" });
|
||||
expect(request.message.subscribe).toEqual({ subscriptionId: "sub-1" });
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
@@ -1715,7 +1851,7 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => {
|
||||
message: {
|
||||
type: "fetch_agents_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
subscriptionId: "sub-1",
|
||||
entries: [],
|
||||
pageInfo: {
|
||||
@@ -1729,7 +1865,7 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => {
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
subscriptionId: "sub-1",
|
||||
entries: [],
|
||||
pageInfo: {
|
||||
@@ -1763,8 +1899,15 @@ test("sends active-scoped fetch_agents_request", async () => {
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toMatchObject({
|
||||
const request = JSON.parse(String(mock.sent[0])) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "fetch_agents_request";
|
||||
requestId: string;
|
||||
scope?: "active";
|
||||
};
|
||||
};
|
||||
expect(request.message).toMatchObject({
|
||||
type: "fetch_agents_request",
|
||||
scope: "active",
|
||||
});
|
||||
@@ -1775,7 +1918,7 @@ test("sends active-scoped fetch_agents_request", async () => {
|
||||
message: {
|
||||
type: "fetch_agents_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
entries: [],
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
@@ -1788,7 +1931,7 @@ test("sends active-scoped fetch_agents_request", async () => {
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toMatchObject({
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
entries: [],
|
||||
});
|
||||
});
|
||||
@@ -1816,9 +1959,16 @@ test("fetches paginated agent history separately from active agents", async () =
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.type).toBe("fetch_agent_history_request");
|
||||
expect(request.page).toEqual({ limit: 25, cursor: "cursor-1" });
|
||||
const request = JSON.parse(String(mock.sent[0])) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "fetch_agent_history_request";
|
||||
requestId: string;
|
||||
page?: { limit: number; cursor?: string };
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("fetch_agent_history_request");
|
||||
expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" });
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
@@ -1826,7 +1976,7 @@ test("fetches paginated agent history separately from active agents", async () =
|
||||
message: {
|
||||
type: "fetch_agent_history_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
entries: [],
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
@@ -1839,7 +1989,7 @@ test("fetches paginated agent history separately from active agents", async () =
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
entries: [],
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
@@ -1887,9 +2037,7 @@ test("uses server-provided dictation finish timeout budget", async () => {
|
||||
await vi.advanceTimersByTimeAsync(5_101);
|
||||
const error = await finishError;
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
if (error instanceof Error) {
|
||||
expect(error.message).toContain("Timeout waiting for dictation finalization (5100ms)");
|
||||
}
|
||||
expect((error as Error).message).toContain("Timeout waiting for dictation finalization (5100ms)");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -1999,8 +2147,11 @@ test("lists available providers via RPC", async () => {
|
||||
const promise = client.listAvailableProviders();
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.type).toBe("list_available_providers_request");
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: { type: "list_available_providers_request"; requestId: string };
|
||||
};
|
||||
expect(request.message.type).toBe("list_available_providers_request");
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
@@ -2014,7 +2165,7 @@ test("lists available providers via RPC", async () => {
|
||||
],
|
||||
error: null,
|
||||
fetchedAt: "2026-02-12T00:00:00.000Z",
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -2027,7 +2178,7 @@ test("lists available providers via RPC", async () => {
|
||||
],
|
||||
error: null,
|
||||
fetchedAt: "2026-02-12T00:00:00.000Z",
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2059,10 +2210,24 @@ test("lists commands with draft config via RPC", async () => {
|
||||
});
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.type).toBe("list_commands_request");
|
||||
expect(request.agentId).toBe("__new_agent__");
|
||||
expect(request.draftConfig).toEqual({
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "list_commands_request";
|
||||
agentId: string;
|
||||
draftConfig?: {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
};
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("list_commands_request");
|
||||
expect(request.message.agentId).toBe("__new_agent__");
|
||||
expect(request.message.draftConfig).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "bypassPermissions",
|
||||
@@ -2079,7 +2244,7 @@ test("lists commands with draft config via RPC", async () => {
|
||||
agentId: "__new_agent__",
|
||||
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
||||
error: null,
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -2089,7 +2254,7 @@ test("lists commands with draft config via RPC", async () => {
|
||||
agentId: "__new_agent__",
|
||||
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
||||
error: null,
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2113,11 +2278,19 @@ test("lists commands with legacy requestId signature via RPC", async () => {
|
||||
const promise = client.listCommands("agent-1", "req-legacy");
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.type).toBe("list_commands_request");
|
||||
expect(request.agentId).toBe("agent-1");
|
||||
expect(request.requestId).toBe("req-legacy");
|
||||
expect(request.draftConfig).toBeUndefined();
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "list_commands_request";
|
||||
agentId: string;
|
||||
draftConfig?: unknown;
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("list_commands_request");
|
||||
expect(request.message.agentId).toBe("agent-1");
|
||||
expect(request.message.requestId).toBe("req-legacy");
|
||||
expect(request.message.draftConfig).toBeUndefined();
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
@@ -2515,17 +2688,25 @@ test("parses canonical agent_stream tool_call payloads without crashing", async
|
||||
unsubscribe();
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toMatchObject({
|
||||
const streamMsg = received[0] as {
|
||||
payload: {
|
||||
event: {
|
||||
type: "timeline";
|
||||
item: {
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "shell" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
type: "tool_call";
|
||||
status: string;
|
||||
error: unknown;
|
||||
detail: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(streamMsg.payload.event.item.status).toBe("running");
|
||||
expect(streamMsg.payload.event.item.error).toBeNull();
|
||||
expect(streamMsg.payload.event.item.detail.type).toBe("shell");
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2651,20 +2832,28 @@ test("parses canonical fetch_agent_timeline_response payloads without crashing",
|
||||
unsubscribe();
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toMatchObject({
|
||||
const timelineMsg = received[0] as {
|
||||
payload: {
|
||||
entries: [
|
||||
{
|
||||
item: {
|
||||
type: "tool_call",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "shell" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
entries: Array<{
|
||||
item: {
|
||||
type: "tool_call";
|
||||
status: string;
|
||||
error: unknown;
|
||||
detail: {
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const firstEntry = timelineMsg.payload.entries[0];
|
||||
expect(firstEntry?.item.type).toBe("tool_call");
|
||||
if (firstEntry?.item.type === "tool_call") {
|
||||
expect(firstEntry.item.status).toBe("running");
|
||||
expect(firstEntry.item.error).toBeNull();
|
||||
expect(firstEntry.item.detail.type).toBe("shell");
|
||||
}
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2761,14 +2950,14 @@ test("sends subscribe/unsubscribe terminals messages", async () => {
|
||||
client.unsubscribeTerminals({ cwd: "/tmp/project" });
|
||||
|
||||
expect(mock.sent).toHaveLength(2);
|
||||
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
||||
expect(JSON.parse(String(mock.sent[0]))).toEqual({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "subscribe_terminals_request",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(assertStr(mock.sent[1]))).toEqual({
|
||||
expect(JSON.parse(String(mock.sent[1]))).toEqual({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "unsubscribe_terminals_request",
|
||||
@@ -2852,7 +3041,7 @@ test("sends close_items_request and resolves close_items_response", async () =>
|
||||
"req-close-items",
|
||||
);
|
||||
|
||||
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
||||
expect(JSON.parse(String(mock.sent[0]))).toEqual({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "close_items_request",
|
||||
@@ -2902,10 +3091,18 @@ test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline",
|
||||
const waitPromise = client.waitForFinish("agent-wait-zero-timeout", 0);
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.type).toBe("wait_for_finish_request");
|
||||
expect(request.agentId).toBe("agent-wait-zero-timeout");
|
||||
expect(request).not.toHaveProperty("timeoutMs");
|
||||
const request = JSON.parse(String(mock.sent[0])) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "wait_for_finish_request";
|
||||
requestId: string;
|
||||
agentId: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("wait_for_finish_request");
|
||||
expect(request.message.agentId).toBe("agent-wait-zero-timeout");
|
||||
expect(request.message).not.toHaveProperty("timeoutMs");
|
||||
|
||||
const settled = vi.fn();
|
||||
void waitPromise.then(
|
||||
@@ -2920,7 +3117,7 @@ test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline",
|
||||
wrapSessionMessage({
|
||||
type: "wait_for_finish_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
requestId: request.message.requestId,
|
||||
status: "idle",
|
||||
final: null,
|
||||
error: null,
|
||||
|
||||
@@ -601,8 +601,8 @@ export interface WaitForFinishResult {
|
||||
|
||||
interface Waiter<T> {
|
||||
predicate: (msg: SessionOutboundMessage) => T | null;
|
||||
resolve(value: T): void;
|
||||
reject(error: Error): void;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
@@ -1160,7 +1160,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
const type = arg1;
|
||||
const handler = arg2!;
|
||||
const handler = arg2 as (message: SessionOutboundMessage) => void;
|
||||
|
||||
if (!this.messageHandlers.has(type)) {
|
||||
this.messageHandlers.set(type, new Set());
|
||||
@@ -4043,13 +4043,7 @@ export class DaemonClient {
|
||||
|
||||
const parsed = WSOutboundMessageSchema.safeParse(parsedJson);
|
||||
if (!parsed.success) {
|
||||
const msgType =
|
||||
parsedJson != null &&
|
||||
typeof parsedJson === "object" &&
|
||||
"type" in parsedJson &&
|
||||
typeof parsedJson.type === "string"
|
||||
? parsedJson.type
|
||||
: "unknown";
|
||||
const msgType = (parsedJson as { type?: string })?.type ?? "unknown";
|
||||
this.logger.warn({ msgType, error: parsed.error.message }, "Message validation failed");
|
||||
return;
|
||||
}
|
||||
@@ -4411,7 +4405,7 @@ export class DaemonClient {
|
||||
timeout > 0
|
||||
? setTimeout(() => {
|
||||
if (waiter) {
|
||||
this.waiters.delete(waiter);
|
||||
this.waiters.delete(waiter as Waiter<unknown>);
|
||||
}
|
||||
wrappedReject(timeoutError);
|
||||
}, timeout)
|
||||
@@ -4423,7 +4417,7 @@ export class DaemonClient {
|
||||
reject: wrappedReject,
|
||||
timeoutHandle,
|
||||
};
|
||||
this.waiters.add(waiter);
|
||||
this.waiters.add(waiter as Waiter<unknown>);
|
||||
});
|
||||
|
||||
const cancel = (error: Error) => {
|
||||
@@ -4432,7 +4426,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
if (waiter) {
|
||||
this.waiters.delete(waiter);
|
||||
this.waiters.delete(waiter as Waiter<unknown>);
|
||||
if (waiter.timeoutHandle) {
|
||||
clearTimeout(waiter.timeoutHandle);
|
||||
}
|
||||
|
||||
@@ -1647,7 +1647,7 @@ export class AgentManager {
|
||||
{ agentId, foregroundTurnId },
|
||||
"cancelAgentRun: foreground turn still active after timeout, force-canceling",
|
||||
);
|
||||
void this.dispatchSessionEvent(agent, {
|
||||
this.dispatchSessionEvent(agent, {
|
||||
type: "turn_canceled",
|
||||
provider: agent.provider,
|
||||
reason: "interrupted",
|
||||
|
||||
@@ -22,7 +22,7 @@ interface McpToolResult {
|
||||
}
|
||||
|
||||
interface McpClient {
|
||||
callTool: (input: { name: string; args?: StructuredContent }) => Promise<McpToolResult>;
|
||||
callTool: (input: { name: string; args?: StructuredContent }) => Promise<unknown>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -61,29 +61,23 @@ function getStructuredContent(result: McpToolResult): StructuredContent | null {
|
||||
}
|
||||
const content = result.content?.[0];
|
||||
if (content && typeof content === "object" && "structuredContent" in content) {
|
||||
if (content.structuredContent) return content.structuredContent;
|
||||
const structured = (content as { structuredContent?: StructuredContent }).structuredContent;
|
||||
if (structured) return structured;
|
||||
}
|
||||
if (content && typeof content === "object") {
|
||||
return content;
|
||||
return content as StructuredContent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createMcpClient(url: string): Promise<McpClient> {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url));
|
||||
const rawClient = await experimental_createMCPClient({ transport });
|
||||
const boundCallTool: McpClient["callTool"] = Reflect.get(rawClient, "callTool").bind(rawClient);
|
||||
return { callTool: boundCallTool, close: () => rawClient.close() };
|
||||
}
|
||||
|
||||
async function waitForAgentCompletion(options: {
|
||||
client: McpClient;
|
||||
agentId: string;
|
||||
}): Promise<void> {
|
||||
const waitResult = await options.client.callTool({
|
||||
const waitResult = (await options.client.callTool({
|
||||
name: "wait_for_agent",
|
||||
args: { agentId: options.agentId },
|
||||
});
|
||||
})) as McpToolResult;
|
||||
const payload = getStructuredContent(waitResult);
|
||||
if (!payload) {
|
||||
throw new Error("wait_for_agent returned no structured payload");
|
||||
@@ -93,7 +87,7 @@ async function waitForAgentCompletion(options: {
|
||||
}
|
||||
const status = payload.status;
|
||||
if (status === "running" || status === "initializing") {
|
||||
throw new Error(`Agent still running after wait_for_agent (status=${status})`);
|
||||
throw new Error(`Agent still running after wait_for_agent (status=${String(status)})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +113,10 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
|
||||
await daemon.start();
|
||||
|
||||
const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`);
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${port}/mcp/agents`),
|
||||
);
|
||||
const client = (await experimental_createMCPClient({ transport })) as McpClient;
|
||||
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
@@ -132,7 +129,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
"Do not respond before the command finishes.",
|
||||
].join("\n");
|
||||
|
||||
const result = await client.callTool({
|
||||
const result = (await client.callTool({
|
||||
name: "create_agent",
|
||||
args: {
|
||||
cwd: agentCwd,
|
||||
@@ -142,10 +139,10 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
initialPrompt,
|
||||
background: false,
|
||||
},
|
||||
});
|
||||
})) as McpToolResult;
|
||||
|
||||
const payload = getStructuredContent(result);
|
||||
agentId = typeof payload?.agentId === "string" ? payload.agentId : null;
|
||||
agentId = (payload?.agentId as string | undefined) ?? null;
|
||||
expect(agentId).toBeTruthy();
|
||||
|
||||
await waitForAgentCompletion({ client, agentId: agentId! });
|
||||
@@ -189,7 +186,10 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
|
||||
await daemon.start();
|
||||
|
||||
const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`);
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${port}/mcp/agents`),
|
||||
);
|
||||
const client = (await experimental_createMCPClient({ transport })) as McpClient;
|
||||
|
||||
const disabledPaseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-disabled-"));
|
||||
const disabledStaticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-disabled-"));
|
||||
@@ -210,12 +210,17 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
const disabledDaemon = await createPaseoDaemon(disabledDaemonConfig, pino({ level: "silent" }));
|
||||
await disabledDaemon.start();
|
||||
|
||||
const disabledClient = await createMcpClient(`http://127.0.0.1:${disabledPort}/mcp/agents`);
|
||||
const disabledTransport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${disabledPort}/mcp/agents`),
|
||||
);
|
||||
const disabledClient = (await experimental_createMCPClient({
|
||||
transport: disabledTransport,
|
||||
})) as McpClient;
|
||||
|
||||
let agentId: string | null = null;
|
||||
let disabledAgentId: string | null = null;
|
||||
try {
|
||||
const result = await client.callTool({
|
||||
const result = (await client.callTool({
|
||||
name: "create_agent",
|
||||
args: {
|
||||
cwd: agentCwd,
|
||||
@@ -225,9 +230,9 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
initialPrompt: "reply with done and stop",
|
||||
background: true,
|
||||
},
|
||||
});
|
||||
})) as McpToolResult;
|
||||
const payload = getStructuredContent(result);
|
||||
agentId = typeof payload?.agentId === "string" ? payload.agentId : null;
|
||||
agentId = (payload?.agentId as string | undefined) ?? null;
|
||||
expect(agentId).toBeTruthy();
|
||||
|
||||
const injectedAgent = daemon.agentManager.getAgent(agentId!);
|
||||
@@ -238,7 +243,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const disabledResult = await disabledClient.callTool({
|
||||
const disabledResult = (await disabledClient.callTool({
|
||||
name: "create_agent",
|
||||
args: {
|
||||
cwd: disabledAgentCwd,
|
||||
@@ -248,10 +253,9 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
initialPrompt: "reply with done and stop",
|
||||
background: true,
|
||||
},
|
||||
});
|
||||
})) as McpToolResult;
|
||||
const disabledPayload = getStructuredContent(disabledResult);
|
||||
disabledAgentId =
|
||||
typeof disabledPayload?.agentId === "string" ? disabledPayload.agentId : null;
|
||||
disabledAgentId = (disabledPayload?.agentId as string | undefined) ?? null;
|
||||
expect(disabledAgentId).toBeTruthy();
|
||||
|
||||
const disabledAgent = disabledDaemon.agentManager.getAgent(disabledAgentId!);
|
||||
@@ -297,7 +301,10 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
|
||||
await daemon.start();
|
||||
|
||||
const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`);
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${port}/mcp/agents`),
|
||||
);
|
||||
const client = (await experimental_createMCPClient({ transport })) as McpClient;
|
||||
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
@@ -332,7 +339,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const result = await withTimeout({
|
||||
const result = (await withTimeout({
|
||||
promise: client.callTool({
|
||||
name: "create_agent",
|
||||
args: {
|
||||
@@ -348,12 +355,12 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
}),
|
||||
timeoutMs: 2500,
|
||||
label: "create_agent should not block on setup",
|
||||
});
|
||||
})) as McpToolResult;
|
||||
|
||||
const payload = getStructuredContent(result);
|
||||
agentId = typeof payload?.agentId === "string" ? payload.agentId : null;
|
||||
agentId = (payload?.agentId as string | undefined) ?? null;
|
||||
expect(agentId).toBeTruthy();
|
||||
const worktreePath = typeof payload?.cwd === "string" ? payload.cwd : "";
|
||||
const worktreePath = (payload?.cwd as string | undefined) ?? "";
|
||||
expect(worktreePath).toContain(`${path.sep}worktrees${path.sep}`);
|
||||
expect(existsSync(path.join(worktreePath, "setup-done.txt"))).toBe(false);
|
||||
expect(existsSync(path.join(worktreePath, "dev-terminal.txt"))).toBe(false);
|
||||
|
||||
@@ -94,7 +94,7 @@ function fetchAfter(ctx: FetchContext): AgentTimelineFetchResult {
|
||||
gap: false,
|
||||
window,
|
||||
hasOlder: selected[0].seq > minSeq,
|
||||
hasNewer: lastSelected !== null && lastSelected !== undefined && lastSelected.seq < maxSeq,
|
||||
hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq),
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { experimental_createMCPClient } from "ai";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AGENT_WAIT_TIMEOUT_MS } from "./mcp-shared.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
@@ -21,22 +20,10 @@ interface McpToolResult {
|
||||
}
|
||||
|
||||
interface McpClient {
|
||||
callTool: (input: { name: string; args?: StructuredContent }) => Promise<McpToolResult>;
|
||||
callTool: (input: { name: string; args?: StructuredContent }) => Promise<unknown>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
function str(val: unknown): string {
|
||||
return z.string().parse(val);
|
||||
}
|
||||
|
||||
function recordArr(val: unknown): StructuredContent[] {
|
||||
return z.array(z.record(z.unknown())).parse(val);
|
||||
}
|
||||
|
||||
function strArrOptional(val: unknown): string[] | undefined {
|
||||
return z.array(z.string()).optional().parse(val);
|
||||
}
|
||||
|
||||
function formatHostForHttpUrl(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
@@ -56,21 +43,20 @@ function getStructuredContent(result: McpToolResult): StructuredContent | null {
|
||||
}
|
||||
const content = result.content?.[0];
|
||||
if (content && typeof content === "object" && "structuredContent" in content) {
|
||||
if (content.structuredContent) {
|
||||
return content.structuredContent;
|
||||
const structured = (content as { structuredContent?: StructuredContent }).structuredContent;
|
||||
if (structured) {
|
||||
return structured;
|
||||
}
|
||||
}
|
||||
if (content && typeof content === "object") {
|
||||
return content;
|
||||
return content as StructuredContent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createMcpClient(url: string): Promise<McpClient> {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url));
|
||||
const rawClient = await experimental_createMCPClient({ transport });
|
||||
const boundCallTool: McpClient["callTool"] = Reflect.get(rawClient, "callTool").bind(rawClient);
|
||||
return { callTool: boundCallTool, close: () => rawClient.close() };
|
||||
return (await experimental_createMCPClient({ transport })) as McpClient;
|
||||
}
|
||||
|
||||
async function callToolStructured(
|
||||
@@ -78,7 +64,7 @@ async function callToolStructured(
|
||||
name: string,
|
||||
args?: StructuredContent,
|
||||
): Promise<StructuredContent> {
|
||||
const result = await client.callTool({ name, args: args ?? {} });
|
||||
const result = (await client.callTool({ name, args: args ?? {} })) as McpToolResult;
|
||||
const payload = getStructuredContent(result);
|
||||
if (!payload) {
|
||||
throw new Error(`${name} returned no structured payload`);
|
||||
@@ -92,14 +78,10 @@ async function expectToolError(
|
||||
args: StructuredContent,
|
||||
pattern: RegExp,
|
||||
): Promise<void> {
|
||||
const result = await client.callTool({ name, args });
|
||||
const result = (await client.callTool({ name, args })) as McpToolResult;
|
||||
expect(result.isError).toBe(true);
|
||||
const contentItem = result.content?.[0];
|
||||
const contentText: string | undefined =
|
||||
contentItem != null && typeof contentItem === "object"
|
||||
? Reflect.get(contentItem, "text")
|
||||
: undefined;
|
||||
expect(contentText ?? "").toMatch(pattern);
|
||||
const content = result.content?.[0] as { text?: string } | undefined;
|
||||
expect(content?.text ?? "").toMatch(pattern);
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
@@ -136,7 +118,7 @@ async function makeCwd(prefix: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<string> {
|
||||
const cwd = typeof args?.cwd === "string" ? args.cwd : await makeCwd("agent-cwd");
|
||||
const cwd = (args?.cwd as string | undefined) ?? (await makeCwd("agent-cwd"));
|
||||
const payload = await callToolStructured(topLevelClient, "create_agent", {
|
||||
cwd,
|
||||
title: "Parity agent",
|
||||
@@ -146,7 +128,7 @@ async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<s
|
||||
background: true,
|
||||
...args,
|
||||
});
|
||||
return str(payload.agentId);
|
||||
return payload.agentId as string;
|
||||
}
|
||||
|
||||
async function createChildAgent(args?: Partial<StructuredContent>): Promise<string> {
|
||||
@@ -157,7 +139,7 @@ async function createChildAgent(args?: Partial<StructuredContent>): Promise<stri
|
||||
background: true,
|
||||
...args,
|
||||
});
|
||||
return str(payload.agentId);
|
||||
return payload.agentId as string;
|
||||
}
|
||||
|
||||
async function archiveAgentIfPresent(agentId: string | null | undefined): Promise<void> {
|
||||
@@ -231,7 +213,7 @@ beforeAll(async () => {
|
||||
mode: "bypassPermissions",
|
||||
background: true,
|
||||
});
|
||||
parentAgentId = str(parentPayload.agentId);
|
||||
parentAgentId = parentPayload.agentId as string;
|
||||
|
||||
agentScopedClient = await createMcpClient(
|
||||
`http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${parentAgentId}`,
|
||||
@@ -380,10 +362,10 @@ describe("Suite B: Terminal Tools", () => {
|
||||
const created = await callToolStructured(agentScopedClient, "create_terminal", {
|
||||
name: "Parity terminal",
|
||||
});
|
||||
terminalId = str(created.id);
|
||||
terminalId = created.id as string;
|
||||
|
||||
const listed = await callToolStructured(agentScopedClient, "list_terminals");
|
||||
const terminals = recordArr(listed.terminals);
|
||||
const terminals = listed.terminals as Array<StructuredContent>;
|
||||
expect(terminals).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -404,7 +386,7 @@ describe("Suite B: Terminal Tools", () => {
|
||||
const created = await callToolStructured(agentScopedClient, "create_terminal", {
|
||||
name: "Parity capture terminal",
|
||||
});
|
||||
terminalId = str(created.id);
|
||||
terminalId = created.id as string;
|
||||
|
||||
await callToolStructured(agentScopedClient, "send_terminal_keys", {
|
||||
terminalId,
|
||||
@@ -422,7 +404,7 @@ describe("Suite B: Terminal Tools", () => {
|
||||
terminalId,
|
||||
scrollback: true,
|
||||
});
|
||||
const lines = strArrOptional(payload.lines) ?? [];
|
||||
const lines = (payload.lines as string[] | undefined) ?? [];
|
||||
return lines.some((line) => line.includes("hello")) ? payload : null;
|
||||
},
|
||||
});
|
||||
@@ -439,7 +421,7 @@ describe("Suite B: Terminal Tools", () => {
|
||||
const created = await callToolStructured(agentScopedClient, "create_terminal", {
|
||||
name: "Parity kill terminal",
|
||||
});
|
||||
terminalId = str(created.id);
|
||||
terminalId = created.id as string;
|
||||
|
||||
await callToolStructured(agentScopedClient, "kill_terminal", { terminalId });
|
||||
terminalId = null;
|
||||
@@ -450,11 +432,11 @@ describe("Suite B: Terminal Tools", () => {
|
||||
label: "terminal removal",
|
||||
check: async () => {
|
||||
const payload = await callToolStructured(agentScopedClient, "list_terminals");
|
||||
const terminals = recordArr(payload.terminals);
|
||||
const terminals = payload.terminals as Array<StructuredContent>;
|
||||
return terminals.some((terminal) => terminal.id === created.id) ? null : payload;
|
||||
},
|
||||
});
|
||||
const terminals = recordArr(listed.terminals);
|
||||
const terminals = listed.terminals as Array<StructuredContent>;
|
||||
expect(terminals.some((terminal) => terminal.id === created.id)).toBe(false);
|
||||
} finally {
|
||||
await killTerminalIfPresent(terminalId);
|
||||
@@ -480,10 +462,10 @@ describe("Suite C: Schedule Tools", () => {
|
||||
every: "5m",
|
||||
name: "Parity schedule list",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
scheduleId = created.id as string;
|
||||
|
||||
const listed = await callToolStructured(topLevelClient, "list_schedules");
|
||||
const schedules = recordArr(listed.schedules);
|
||||
const schedules = listed.schedules as Array<StructuredContent>;
|
||||
expect(schedules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -506,7 +488,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
name: "Parity provider schedule",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
scheduleId = created.id as string;
|
||||
expect(created.target).toMatchObject({
|
||||
type: "new-agent",
|
||||
config: {
|
||||
@@ -527,7 +509,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
every: "5m",
|
||||
name: "Parity inspect schedule",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
scheduleId = created.id as string;
|
||||
|
||||
const inspected = await callToolStructured(topLevelClient, "inspect_schedule", {
|
||||
id: scheduleId,
|
||||
@@ -551,7 +533,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
every: "5m",
|
||||
name: "Parity pause schedule",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
scheduleId = created.id as string;
|
||||
|
||||
await callToolStructured(topLevelClient, "pause_schedule", { id: scheduleId });
|
||||
const paused = await callToolStructured(topLevelClient, "inspect_schedule", {
|
||||
@@ -577,13 +559,13 @@ describe("Suite C: Schedule Tools", () => {
|
||||
every: "5m",
|
||||
name: "Parity delete schedule",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
scheduleId = created.id as string;
|
||||
|
||||
await callToolStructured(topLevelClient, "delete_schedule", { id: scheduleId });
|
||||
scheduleId = null;
|
||||
|
||||
const listed = await callToolStructured(topLevelClient, "list_schedules");
|
||||
const schedules = recordArr(listed.schedules);
|
||||
const schedules = listed.schedules as Array<StructuredContent>;
|
||||
expect(schedules.some((schedule) => schedule.id === created.id)).toBe(false);
|
||||
} finally {
|
||||
await deleteScheduleIfPresent(scheduleId);
|
||||
@@ -599,7 +581,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
name: "Parity self schedule",
|
||||
target: "self",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
scheduleId = created.id as string;
|
||||
expect(created.target).toMatchObject({
|
||||
type: "agent",
|
||||
agentId: parentAgentId,
|
||||
@@ -617,7 +599,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
every: "5m",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
scheduleId = created.id as string;
|
||||
expect(created.target).toMatchObject({
|
||||
type: "new-agent",
|
||||
config: {
|
||||
@@ -647,7 +629,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
describe("Suite D: Provider Tools", () => {
|
||||
test("list_providers returns providers", async () => {
|
||||
const payload = await callToolStructured(topLevelClient, "list_providers");
|
||||
const providers = recordArr(payload.providers);
|
||||
const providers = payload.providers as Array<StructuredContent>;
|
||||
expect(Array.isArray(providers)).toBe(true);
|
||||
expect(providers.length).toBeGreaterThan(0);
|
||||
expect(providers[0]).toEqual(
|
||||
@@ -685,12 +667,12 @@ describe("Suite E: Worktree Tools", () => {
|
||||
branchName,
|
||||
baseBranch: "main",
|
||||
});
|
||||
worktreePath = str(created.worktreePath);
|
||||
worktreePath = created.worktreePath as string;
|
||||
|
||||
const listed = await callToolStructured(topLevelClient, "list_worktrees", {
|
||||
cwd: worktreeRepoCwd,
|
||||
});
|
||||
const worktrees = recordArr(listed.worktrees);
|
||||
const worktrees = listed.worktrees as Array<StructuredContent>;
|
||||
expect(worktrees).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -713,7 +695,7 @@ describe("Suite E: Worktree Tools", () => {
|
||||
branchName,
|
||||
baseBranch: "main",
|
||||
});
|
||||
worktreePath = str(created.worktreePath);
|
||||
worktreePath = created.worktreePath as string;
|
||||
|
||||
await callToolStructured(topLevelClient, "archive_worktree", {
|
||||
cwd: worktreeRepoCwd,
|
||||
@@ -724,7 +706,7 @@ describe("Suite E: Worktree Tools", () => {
|
||||
const listed = await callToolStructured(topLevelClient, "list_worktrees", {
|
||||
cwd: worktreeRepoCwd,
|
||||
});
|
||||
const worktrees = recordArr(listed.worktrees);
|
||||
const worktrees = listed.worktrees as Array<StructuredContent>;
|
||||
expect(worktrees.some((worktree) => worktree.path === created.worktreePath)).toBe(false);
|
||||
} finally {
|
||||
await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath });
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AgentListItemPayloadSchema, AgentSnapshotPayloadSchema } from "../../sh
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js";
|
||||
import type { CreateScheduleInput, StoredSchedule } from "../schedule/types.js";
|
||||
import type { ScheduleService } from "../schedule/service.js";
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
import {
|
||||
createPaseoWorktree as createPaseoWorktreeService,
|
||||
@@ -47,12 +48,15 @@ interface RegisteredMcpTool {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface McpServerInternals {
|
||||
_registeredTools: Record<string, RegisteredMcpTool>;
|
||||
}
|
||||
|
||||
function lookupTool(
|
||||
server: Awaited<ReturnType<typeof createAgentMcpServer>>,
|
||||
name: string,
|
||||
): RegisteredMcpTool | undefined {
|
||||
const tools: Record<string, RegisteredMcpTool> = Reflect.get(server, "_registeredTools");
|
||||
return tools[name];
|
||||
return (server as unknown as McpServerInternals)._registeredTools[name];
|
||||
}
|
||||
|
||||
function registeredTool(
|
||||
@@ -69,7 +73,7 @@ function registeredTool(
|
||||
function agentsOf(response: {
|
||||
structuredContent: LooseStructuredContent;
|
||||
}): Array<Record<string, unknown>> {
|
||||
return z.array(z.record(z.unknown())).parse(response.structuredContent.agents);
|
||||
return response.structuredContent.agents as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
type AgentManagerSpies = ReturnType<typeof buildAgentManagerSpies>;
|
||||
@@ -732,7 +736,7 @@ describe("create_agent MCP tool", () => {
|
||||
background: true,
|
||||
});
|
||||
|
||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||
const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string;
|
||||
const initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim();
|
||||
@@ -804,7 +808,7 @@ describe("create_agent MCP tool", () => {
|
||||
background: true,
|
||||
});
|
||||
|
||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||
const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string;
|
||||
expect(
|
||||
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
|
||||
).toBe("existing-feature");
|
||||
@@ -1607,9 +1611,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({});
|
||||
const structured = z
|
||||
.object({ agents: z.array(z.record(z.unknown())) })
|
||||
.parse(response.structuredContent);
|
||||
const structured = response.structuredContent as { agents: Array<Record<string, unknown>> };
|
||||
|
||||
expect(structured).toEqual({
|
||||
agents: [
|
||||
@@ -1657,7 +1659,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
},
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
const response = await tool.callback({ agentId: "archived-agent" });
|
||||
@@ -1717,7 +1719,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
const response = await tool.callback({ agentId: "full-detail-agent" });
|
||||
const snapshot = z.record(z.unknown()).parse(response.structuredContent.snapshot);
|
||||
const snapshot = response.structuredContent.snapshot as Record<string, unknown>;
|
||||
|
||||
const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot);
|
||||
if (!parsed.success) {
|
||||
@@ -1788,7 +1790,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
},
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
|
||||
@@ -1832,7 +1834,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
},
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
callerAgentId: "caller-agent",
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
@@ -1881,7 +1883,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
},
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({
|
||||
@@ -1922,7 +1924,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
},
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({ includeArchived: true });
|
||||
@@ -1965,7 +1967,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
},
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({ cwd: "/tmp/repo", includeArchived: true });
|
||||
@@ -2073,7 +2075,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
},
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = registeredTool(server, "list_agents");
|
||||
const response = await tool.callback({ includeArchived: true });
|
||||
@@ -2113,7 +2115,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
},
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_activity");
|
||||
const response = await tool.callback({ agentId: "archived-activity-agent" });
|
||||
@@ -2151,7 +2153,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger: createTestLogger(),
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
},
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_activity");
|
||||
const response = await tool.callback({ agentId: "live-activity-agent", limit: 1 });
|
||||
@@ -2182,7 +2184,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
logger: createTestLogger(),
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
},
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_activity");
|
||||
const response = await tool.callback({ agentId: "live-activity-agent-2", limit: 2 });
|
||||
|
||||
@@ -39,7 +39,7 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
|
||||
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { captureTerminalLines } from "../../terminal/terminal-capture.js";
|
||||
import { captureTerminalLines } from "../../terminal/terminal.js";
|
||||
import type {
|
||||
AgentWorktreeSetupContinuation,
|
||||
CreatePaseoWorktreeSetupContinuationInput,
|
||||
@@ -2104,8 +2104,6 @@ function mcpCreateWorktreeInput(
|
||||
return {
|
||||
input: { ...base, action: "checkout", githubPrNumber: target.prNumber },
|
||||
};
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +1,49 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { setupFinishNotification } from "./mcp-shared.js";
|
||||
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentManager, AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
|
||||
it("does not notify archived callers", async () => {
|
||||
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
||||
|
||||
const childAgent: ManagedAgent = Object.create(null);
|
||||
Reflect.set(childAgent, "id", "child-agent");
|
||||
Reflect.set(childAgent, "lifecycle", "idle");
|
||||
Reflect.set(childAgent, "config", { title: "Child Agent" });
|
||||
const childAgent = {
|
||||
id: "child-agent",
|
||||
lifecycle: "idle",
|
||||
config: { title: "Child Agent" },
|
||||
} as ManagedAgent;
|
||||
|
||||
const callerAgent: ManagedAgent = Object.create(null);
|
||||
Reflect.set(callerAgent, "id", "caller-agent");
|
||||
Reflect.set(callerAgent, "lifecycle", "idle");
|
||||
Reflect.set(callerAgent, "config", { title: "Caller Agent" });
|
||||
|
||||
const streamAgentSpy = vi.fn(() => (async function* noop() {})());
|
||||
const replaceAgentRunSpy = vi.fn(() => (async function* noop() {})());
|
||||
|
||||
const agentManager: AgentManager = Object.create(AgentManager.prototype);
|
||||
Reflect.set(
|
||||
agentManager,
|
||||
"getAgent",
|
||||
vi.fn((agentId: string) => {
|
||||
const agentManager = {
|
||||
getAgent: vi.fn((agentId: string) => {
|
||||
if (agentId === "child-agent") {
|
||||
return childAgent;
|
||||
}
|
||||
if (agentId === "caller-agent") {
|
||||
return callerAgent;
|
||||
return {
|
||||
id: "caller-agent",
|
||||
lifecycle: "idle",
|
||||
config: { title: "Caller Agent" },
|
||||
} as ManagedAgent;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
Reflect.set(
|
||||
agentManager,
|
||||
"subscribe",
|
||||
vi.fn((callback: (event: AgentManagerEvent) => void) => {
|
||||
subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => {
|
||||
subscriber = callback;
|
||||
return () => {
|
||||
subscriber = null;
|
||||
};
|
||||
}),
|
||||
);
|
||||
Reflect.set(agentManager, "hasInFlightRun", vi.fn().mockReturnValue(false));
|
||||
Reflect.set(agentManager, "streamAgent", streamAgentSpy);
|
||||
Reflect.set(agentManager, "replaceAgentRun", replaceAgentRunSpy);
|
||||
hasInFlightRun: vi.fn().mockReturnValue(false),
|
||||
streamAgent: vi.fn(() => (async function* noop() {})()),
|
||||
replaceAgentRun: vi.fn(() => (async function* noop() {})()),
|
||||
} as unknown as AgentManager;
|
||||
|
||||
const agentStorageGetSpy = vi.fn(async (agentId: string) =>
|
||||
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
|
||||
);
|
||||
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
|
||||
Reflect.set(agentStorage, "get", agentStorageGetSpy);
|
||||
const agentStorage = {
|
||||
get: vi.fn(async (agentId: string) =>
|
||||
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
|
||||
),
|
||||
} as unknown as AgentStorage;
|
||||
|
||||
setupFinishNotification({
|
||||
agentManager,
|
||||
@@ -79,9 +68,13 @@ it("does not notify archived callers", async () => {
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent");
|
||||
expect(agentStorage.get).toHaveBeenCalledWith("caller-agent");
|
||||
});
|
||||
|
||||
expect(streamAgentSpy).not.toHaveBeenCalled();
|
||||
expect(replaceAgentRunSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(agentManager as unknown as { streamAgent: ReturnType<typeof vi.fn> }).streamAgent,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(agentManager as unknown as { replaceAgentRun: ReturnType<typeof vi.fn> }).replaceAgentRun,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -327,11 +327,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
return;
|
||||
}
|
||||
if (event.agent.lifecycle === "error") {
|
||||
void notify("errored");
|
||||
notify("errored");
|
||||
return;
|
||||
}
|
||||
if (event.agent.lifecycle === "idle" && hasSeenRunning) {
|
||||
void notify("finished");
|
||||
notify("finished");
|
||||
return;
|
||||
}
|
||||
if (event.agent.lifecycle === "closed") {
|
||||
@@ -343,7 +343,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
}
|
||||
|
||||
if (event.event.type === "permission_requested") {
|
||||
void notify("needs permission");
|
||||
notify("needs permission");
|
||||
}
|
||||
},
|
||||
{ agentId: childAgentId, replayState: false },
|
||||
@@ -362,7 +362,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
if (childSnapshot.lifecycle === "running") {
|
||||
hasSeenRunning = true;
|
||||
} else if (childSnapshot.lifecycle === "error") {
|
||||
void notify("errored");
|
||||
notify("errored");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Logger } from "pino";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { resolveAgentModel } from "./model-resolver.js";
|
||||
|
||||
vi.mock("./provider-registry.js", () => ({
|
||||
@@ -11,20 +11,10 @@ vi.mock("./provider-registry.js", () => ({
|
||||
import { buildProviderRegistry } from "./provider-registry.js";
|
||||
|
||||
const mockedBuildProviderRegistry = vi.mocked(buildProviderRegistry);
|
||||
const testLogger = createTestLogger();
|
||||
const testLoggerWarn = vi.spyOn(testLogger, "warn");
|
||||
const testLoggerWarn = vi.fn();
|
||||
const testLogger = { warn: testLoggerWarn } as unknown as Logger;
|
||||
type ProviderRegistryMock = ReturnType<typeof buildProviderRegistry>;
|
||||
|
||||
function makeMockRegistry(
|
||||
entries: Record<string, { enabled: boolean; fetchModels: ReturnType<typeof vi.fn> }>,
|
||||
): ProviderRegistryMock {
|
||||
const registry: ProviderRegistryMock = Object.create(null);
|
||||
for (const [key, val] of Object.entries(entries)) {
|
||||
Reflect.set(registry, key, val);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
describe("resolveAgentModel", () => {
|
||||
beforeEach(() => {
|
||||
mockedBuildProviderRegistry.mockReset();
|
||||
@@ -32,13 +22,11 @@ describe("resolveAgentModel", () => {
|
||||
});
|
||||
|
||||
it("returns the trimmed requested model when provided", async () => {
|
||||
mockedBuildProviderRegistry.mockReturnValue(
|
||||
makeMockRegistry({
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: true, fetchModels: vi.fn() },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
}),
|
||||
);
|
||||
mockedBuildProviderRegistry.mockReturnValue({
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: true, fetchModels: vi.fn() },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({
|
||||
provider: "codex",
|
||||
@@ -56,13 +44,11 @@ describe("resolveAgentModel", () => {
|
||||
{ id: "claude-3.5-haiku", isDefault: false },
|
||||
{ id: "claude-3.5-sonnet", isDefault: true },
|
||||
]);
|
||||
mockedBuildProviderRegistry.mockReturnValue(
|
||||
makeMockRegistry({
|
||||
claude: { enabled: true, fetchModels },
|
||||
codex: { enabled: true, fetchModels: vi.fn() },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
}),
|
||||
);
|
||||
mockedBuildProviderRegistry.mockReturnValue({
|
||||
claude: { enabled: true, fetchModels },
|
||||
codex: { enabled: true, fetchModels: vi.fn() },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({
|
||||
provider: "claude",
|
||||
@@ -82,13 +68,11 @@ describe("resolveAgentModel", () => {
|
||||
{ id: "model-a", isDefault: false },
|
||||
{ id: "model-b", isDefault: false },
|
||||
]);
|
||||
mockedBuildProviderRegistry.mockReturnValue(
|
||||
makeMockRegistry({
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: true, fetchModels },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
}),
|
||||
);
|
||||
mockedBuildProviderRegistry.mockReturnValue({
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: true, fetchModels },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
|
||||
|
||||
@@ -97,13 +81,11 @@ describe("resolveAgentModel", () => {
|
||||
|
||||
it("returns undefined when the catalog lookup fails", async () => {
|
||||
const fetchModels = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
mockedBuildProviderRegistry.mockReturnValue(
|
||||
makeMockRegistry({
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: true, fetchModels },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
}),
|
||||
);
|
||||
mockedBuildProviderRegistry.mockReturnValue({
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: true, fetchModels },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
|
||||
|
||||
@@ -113,13 +95,11 @@ describe("resolveAgentModel", () => {
|
||||
|
||||
it("returns undefined for a disabled provider without fetching default models", async () => {
|
||||
const fetchModels = vi.fn().mockResolvedValue([{ id: "model-a", isDefault: true }]);
|
||||
mockedBuildProviderRegistry.mockReturnValue(
|
||||
makeMockRegistry({
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: false, fetchModels },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
}),
|
||||
);
|
||||
mockedBuildProviderRegistry.mockReturnValue({
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: false, fetchModels },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
|
||||
|
||||
@@ -130,13 +110,11 @@ describe("resolveAgentModel", () => {
|
||||
|
||||
it("returns undefined for a requested model from a disabled provider", async () => {
|
||||
const fetchModels = vi.fn();
|
||||
mockedBuildProviderRegistry.mockReturnValue(
|
||||
makeMockRegistry({
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: false, fetchModels },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
}),
|
||||
);
|
||||
mockedBuildProviderRegistry.mockReturnValue({
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: false, fetchModels },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({
|
||||
provider: "codex",
|
||||
|
||||
@@ -54,8 +54,6 @@ export function renderPromptAttachmentAsText(attachment: AgentAttachment): strin
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,12 +23,11 @@ const mockState = vi.hoisted(() => {
|
||||
isCommandAvailable: vi.fn(async (_command: string) => false),
|
||||
runtimeModels: new Map<string, AgentModelDefinition[]>(),
|
||||
reset() {
|
||||
this.constructorArgs.claude = [];
|
||||
this.constructorArgs.codex = [];
|
||||
this.constructorArgs.copilot = [];
|
||||
this.constructorArgs.opencode = [];
|
||||
this.constructorArgs.pi = [];
|
||||
this.constructorArgs.genericAcp = [];
|
||||
for (const key of Object.keys(this.constructorArgs) as Array<
|
||||
keyof typeof this.constructorArgs
|
||||
>) {
|
||||
this.constructorArgs[key] = [];
|
||||
}
|
||||
this.isCommandAvailable.mockReset();
|
||||
this.isCommandAvailable.mockImplementation(async (_command: string) => false);
|
||||
this.runtimeModels.clear();
|
||||
@@ -77,10 +76,8 @@ vi.mock("./providers/claude-agent.js", () => ({
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const command: { mode?: string; argv?: string[] } | undefined =
|
||||
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
|
||||
? Reflect.get(this.runtimeSettings, "command")
|
||||
: undefined;
|
||||
const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } })
|
||||
?.command;
|
||||
if (command?.mode === "replace") {
|
||||
const { isCommandAvailable } = await import("../../utils/executable.js");
|
||||
return await isCommandAvailable(command.argv?.[0] ?? "");
|
||||
@@ -125,10 +122,8 @@ vi.mock("./providers/codex-app-server-agent.js", () => ({
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const command: { mode?: string; argv?: string[] } | undefined =
|
||||
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
|
||||
? Reflect.get(this.runtimeSettings, "command")
|
||||
: undefined;
|
||||
const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } })
|
||||
?.command;
|
||||
if (command?.mode === "replace") {
|
||||
const { isCommandAvailable } = await import("../../utils/executable.js");
|
||||
return await isCommandAvailable(command.argv?.[0] ?? "");
|
||||
@@ -175,10 +170,8 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const command: { mode?: string; argv?: string[] } | undefined =
|
||||
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
|
||||
? Reflect.get(this.runtimeSettings, "command")
|
||||
: undefined;
|
||||
const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } })
|
||||
?.command;
|
||||
if (command?.mode === "replace") {
|
||||
const { isCommandAvailable } = await import("../../utils/executable.js");
|
||||
return await isCommandAvailable(command.argv?.[0] ?? "");
|
||||
@@ -552,19 +545,18 @@ test("derived provider inherits and merges disallowedTools from base", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const zaiArgs = mockState.constructorArgs.claude.find((entry) => {
|
||||
const disallowedTools: string[] | undefined =
|
||||
typeof entry.runtimeSettings === "object" && entry.runtimeSettings !== null
|
||||
? Reflect.get(entry.runtimeSettings, "disallowedTools")
|
||||
: undefined;
|
||||
return Array.isArray(disallowedTools) && disallowedTools.includes("ComputerUse");
|
||||
});
|
||||
const zaiArgs = mockState.constructorArgs.claude.find(
|
||||
(entry) =>
|
||||
Array.isArray((entry.runtimeSettings as { disallowedTools?: string[] })?.disallowedTools) &&
|
||||
(entry.runtimeSettings as { disallowedTools: string[] }).disallowedTools.includes(
|
||||
"ComputerUse",
|
||||
),
|
||||
);
|
||||
expect(zaiArgs).toBeDefined();
|
||||
const zaiDisallowedTools: string[] =
|
||||
typeof zaiArgs!.runtimeSettings === "object" && zaiArgs!.runtimeSettings !== null
|
||||
? Reflect.get(zaiArgs!.runtimeSettings, "disallowedTools")
|
||||
: [];
|
||||
expect(zaiDisallowedTools).toEqual(["WebSearch", "ComputerUse"]);
|
||||
expect((zaiArgs!.runtimeSettings as { disallowedTools: string[] }).disallowedTools).toEqual([
|
||||
"WebSearch",
|
||||
"ComputerUse",
|
||||
]);
|
||||
});
|
||||
|
||||
test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => {
|
||||
@@ -582,13 +574,11 @@ test("extension inherits base override — override claude command, zai extends
|
||||
|
||||
expect(mockState.constructorArgs.claude).toHaveLength(2);
|
||||
expect(
|
||||
mockState.constructorArgs.claude.every((entry) => {
|
||||
const command: { argv?: string[] } | undefined =
|
||||
typeof entry.runtimeSettings === "object" && entry.runtimeSettings !== null
|
||||
? Reflect.get(entry.runtimeSettings, "command")
|
||||
: undefined;
|
||||
return command?.argv?.[0] === "/opt/custom-claude";
|
||||
}),
|
||||
mockState.constructorArgs.claude.every(
|
||||
(entry) =>
|
||||
(entry.runtimeSettings as { command?: { argv?: string[] } }).command?.argv?.[0] ===
|
||||
"/opt/custom-claude",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { transformPiModels } from "./pi-direct-agent.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||
|
||||
interface ACPSessionInternals {
|
||||
sessionId: string | null;
|
||||
@@ -160,7 +159,7 @@ function prepareConfiguredOverrideSession(
|
||||
const setSessionConfigOption = vi.fn(async () => ({
|
||||
configOptions: options.configOptions ?? [],
|
||||
}));
|
||||
const internals = asInternals<ACPConfiguredOverrideInternals>(session);
|
||||
const internals = session as unknown as ACPConfiguredOverrideInternals;
|
||||
internals.sessionId = "session-1";
|
||||
internals.connection = {
|
||||
setSessionMode,
|
||||
@@ -180,7 +179,7 @@ function prepareConfiguredOverrideSession(
|
||||
test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => {
|
||||
const logger = createTestLogger();
|
||||
const childLogger = { warn: vi.fn() };
|
||||
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
||||
vi.spyOn(logger, "child").mockReturnValue(childLogger as unknown as typeof logger);
|
||||
const session = createSessionWithConfig({}, logger);
|
||||
const setSessionConfigOption = vi.fn(async () => ({
|
||||
configOptions: [
|
||||
@@ -194,7 +193,7 @@ test("ACP setModel only uses config-option fallback when the matching select cho
|
||||
},
|
||||
],
|
||||
}));
|
||||
const internals = asInternals<ACPModelSelectionInternals>(session);
|
||||
const internals = session as unknown as ACPModelSelectionInternals;
|
||||
internals.sessionId = "session-1";
|
||||
internals.connection = { setSessionConfigOption };
|
||||
internals.configOptions = [
|
||||
@@ -220,7 +219,7 @@ test("ACP setModel only uses config-option fallback when the matching select cho
|
||||
|
||||
await expect(session.setModel("new-provider-model")).resolves.toBeUndefined();
|
||||
expect(childLogger.warn).toHaveBeenCalledWith(
|
||||
{ value: "new-provider-model" },
|
||||
"new-provider-model",
|
||||
expect.stringContaining("is not a valid claude-acp model config option"),
|
||||
);
|
||||
expect(setSessionConfigOption).not.toHaveBeenCalled();
|
||||
@@ -236,7 +235,7 @@ describe("createLoggedNdJsonStream", () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const stream = createLoggedNdJsonStream(output.writable, input.readable, {
|
||||
logger: asInternals<ReturnType<typeof createTestLogger>>(logger),
|
||||
logger: logger as unknown as ReturnType<typeof createTestLogger>,
|
||||
provider: "gemini",
|
||||
});
|
||||
const reader = stream.readable.getReader();
|
||||
@@ -277,7 +276,7 @@ describe("createLoggedNdJsonStream", () => {
|
||||
};
|
||||
|
||||
const stream = createLoggedNdJsonStream(output.writable, input.readable, {
|
||||
logger: asInternals<ReturnType<typeof createTestLogger>>(logger),
|
||||
logger: logger as unknown as ReturnType<typeof createTestLogger>,
|
||||
provider: "gemini",
|
||||
});
|
||||
const reader = stream.readable.getReader();
|
||||
@@ -476,7 +475,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
modelId: "sonnet",
|
||||
});
|
||||
|
||||
const modeEvents = asInternals<ACPSessionInternals>(validSession).translateSessionUpdate({
|
||||
const modeEvents = (validSession as unknown as ACPSessionInternals).translateSessionUpdate({
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: "default",
|
||||
});
|
||||
@@ -495,7 +494,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
|
||||
const logger = createTestLogger();
|
||||
const childLogger = { warn: vi.fn() };
|
||||
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
||||
vi.spyOn(logger, "child").mockReturnValue(childLogger as unknown as typeof logger);
|
||||
const invalidSession = createSessionWithConfig(
|
||||
{ modeId: "acceptEdits", model: "opus" },
|
||||
logger,
|
||||
@@ -514,11 +513,11 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
expect(invalid.setSessionMode).not.toHaveBeenCalled();
|
||||
expect(invalid.unstableSetSessionModel).not.toHaveBeenCalled();
|
||||
expect(childLogger.warn).toHaveBeenCalledWith(
|
||||
{ value: expect.stringContaining("acceptEdits") },
|
||||
expect.stringContaining("acceptEdits"),
|
||||
expect.stringContaining("not valid"),
|
||||
);
|
||||
expect(childLogger.warn).toHaveBeenCalledWith(
|
||||
{ value: expect.stringContaining("opus") },
|
||||
expect.stringContaining("opus"),
|
||||
expect.stringContaining("not a valid"),
|
||||
);
|
||||
});
|
||||
@@ -541,7 +540,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
|
||||
test("routes config_option_update and refreshes derived mode, model, and thinking state", async () => {
|
||||
const session = createSession();
|
||||
const internals = asInternals<ACPSessionInternals>(session);
|
||||
const internals = session as unknown as ACPSessionInternals;
|
||||
|
||||
const events = internals.translateSessionUpdate({
|
||||
sessionUpdate: "config_option_update",
|
||||
@@ -596,7 +595,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
|
||||
test("keeps pushed mode when a later config_option_update has no mode payload", async () => {
|
||||
const session = createSession();
|
||||
const internals = asInternals<ACPSessionInternals>(session);
|
||||
const internals = session as unknown as ACPSessionInternals;
|
||||
|
||||
internals.translateSessionUpdate({
|
||||
sessionUpdate: "current_mode_update",
|
||||
@@ -617,7 +616,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
|
||||
test("uses last writer when current_mode_update and config_option_update both include a mode", async () => {
|
||||
const session = createSession();
|
||||
const internals = asInternals<ACPSessionInternals>(session);
|
||||
const internals = session as unknown as ACPSessionInternals;
|
||||
|
||||
const configEvents = internals.translateSessionUpdate({
|
||||
sessionUpdate: "config_option_update",
|
||||
@@ -635,7 +634,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
|
||||
test("uses canonical mode returned by setSessionConfigOption response", async () => {
|
||||
const session = createSession();
|
||||
const internals = asInternals<ACPModelSelectionInternals>(session);
|
||||
const internals = session as unknown as ACPModelSelectionInternals;
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => events.push(event));
|
||||
internals.sessionId = "session-1";
|
||||
@@ -665,7 +664,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
|
||||
test("uses canonical model returned by setSessionConfigOption response", async () => {
|
||||
const session = createSession();
|
||||
const internals = asInternals<ACPModelSelectionInternals>(session);
|
||||
const internals = session as unknown as ACPModelSelectionInternals;
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => events.push(event));
|
||||
internals.sessionId = "session-1";
|
||||
@@ -689,7 +688,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
|
||||
test("uses canonical thinking option returned by setSessionConfigOption response", async () => {
|
||||
const session = createSession();
|
||||
const internals = asInternals<ACPModelSelectionInternals>(session);
|
||||
const internals = session as unknown as ACPModelSelectionInternals;
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => events.push(event));
|
||||
internals.sessionId = "session-1";
|
||||
@@ -726,7 +725,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
|
||||
];
|
||||
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
(session as unknown as ACPSessionInternals).sessionId = "session-1";
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string; request?: { id: string } });
|
||||
});
|
||||
@@ -870,7 +869,7 @@ describe("ACPAgentClient modelTransformer", () => {
|
||||
}),
|
||||
},
|
||||
initialize: { agentCapabilities: {} },
|
||||
} as SpawnedACPProcess;
|
||||
} as unknown as SpawnedACPProcess;
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
@@ -916,7 +915,7 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
|
||||
newSession: vi.fn().mockResolvedValue(response),
|
||||
},
|
||||
initialize: { agentCapabilities: {} },
|
||||
} as SpawnedACPProcess;
|
||||
} as unknown as SpawnedACPProcess;
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
@@ -957,7 +956,7 @@ describe("ACPAgentClient listModes", () => {
|
||||
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||
connection: { newSession },
|
||||
initialize: { agentCapabilities: {} },
|
||||
} as SpawnedACPProcess;
|
||||
} as unknown as SpawnedACPProcess;
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
@@ -1008,7 +1007,7 @@ describe("ACPAgentClient listModes", () => {
|
||||
}),
|
||||
},
|
||||
initialize: { agentCapabilities: {} },
|
||||
} as SpawnedACPProcess;
|
||||
} as unknown as SpawnedACPProcess;
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
@@ -1112,7 +1111,7 @@ describe("ACPAgentSession slash commands", () => {
|
||||
|
||||
const listCommandsPromise = session.listCommands();
|
||||
|
||||
asInternals<ACPSessionInternals>(session).translateSessionUpdate({
|
||||
(session as unknown as ACPSessionInternals).translateSessionUpdate({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{
|
||||
@@ -1158,7 +1157,7 @@ describe("ACPAgentSession", () => {
|
||||
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
|
||||
const session = createSession();
|
||||
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
(session as unknown as ACPSessionInternals).sessionId = "session-1";
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string; item?: { type: string; text?: string } });
|
||||
@@ -1170,7 +1169,7 @@ describe("ACPAgentSession", () => {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "assistant-1",
|
||||
content: { type: "text", text: "Hey!" },
|
||||
} as SessionUpdate,
|
||||
} as unknown as SessionUpdate,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
@@ -1178,7 +1177,7 @@ describe("ACPAgentSession", () => {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "assistant-1",
|
||||
content: { type: "text", text: " How are you?" },
|
||||
} as SessionUpdate,
|
||||
} as unknown as SessionUpdate,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
@@ -1186,7 +1185,7 @@ describe("ACPAgentSession", () => {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: "thought-1",
|
||||
content: { type: "text", text: "Thinking" },
|
||||
} as SessionUpdate,
|
||||
} as unknown as SessionUpdate,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
@@ -1194,7 +1193,7 @@ describe("ACPAgentSession", () => {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: "thought-1",
|
||||
content: { type: "text", text: " more" },
|
||||
} as SessionUpdate,
|
||||
} as unknown as SessionUpdate,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
@@ -1202,7 +1201,7 @@ describe("ACPAgentSession", () => {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "user-1",
|
||||
content: { type: "text", text: "hel" },
|
||||
} as SessionUpdate,
|
||||
} as unknown as SessionUpdate,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
@@ -1210,7 +1209,7 @@ describe("ACPAgentSession", () => {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "user-1",
|
||||
content: { type: "text", text: "lo" },
|
||||
} as SessionUpdate,
|
||||
} as unknown as SessionUpdate,
|
||||
});
|
||||
|
||||
const timeline = events
|
||||
@@ -1239,8 +1238,8 @@ describe("ACPAgentSession", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
asInternals<ACPSessionInternals>(session).connection = { prompt };
|
||||
(session as unknown as ACPSessionInternals).sessionId = "session-1";
|
||||
(session as unknown as ACPSessionInternals).connection = { prompt };
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string; turnId?: string });
|
||||
@@ -1253,7 +1252,7 @@ describe("ACPAgentSession", () => {
|
||||
type: "turn_started",
|
||||
turnId,
|
||||
});
|
||||
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBe(turnId);
|
||||
expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBe(turnId);
|
||||
|
||||
resolvePrompt({ stopReason: "end_turn", usage: { outputTokens: 3 } });
|
||||
await Promise.resolve();
|
||||
@@ -1263,7 +1262,7 @@ describe("ACPAgentSession", () => {
|
||||
type: "turn_completed",
|
||||
turnId,
|
||||
});
|
||||
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
|
||||
expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBeNull();
|
||||
});
|
||||
|
||||
test("startTurn converts background prompt rejections into turn_failed events", async () => {
|
||||
@@ -1277,8 +1276,8 @@ describe("ACPAgentSession", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
asInternals<ACPSessionInternals>(session).connection = { prompt };
|
||||
(session as unknown as ACPSessionInternals).sessionId = "session-1";
|
||||
(session as unknown as ACPSessionInternals).connection = { prompt };
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string; turnId?: string; error?: string });
|
||||
@@ -1296,6 +1295,6 @@ describe("ACPAgentSession", () => {
|
||||
turnId,
|
||||
error: "prompt failed",
|
||||
});
|
||||
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
|
||||
expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,6 @@ import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import type {
|
||||
ReadableStream as NodeReadableStream,
|
||||
WritableStream as NodeWritableStream,
|
||||
} from "node:stream/web";
|
||||
import {
|
||||
ClientSideConnection,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -94,18 +90,6 @@ import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provi
|
||||
import { findExecutable } from "../../../utils/executable.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
|
||||
function assertChildWithPipes(
|
||||
child: ChildProcess,
|
||||
): asserts child is ChildProcessWithoutNullStreams {
|
||||
if (!child.stdin || !child.stdout || !child.stderr) {
|
||||
throw new Error("Child process did not expose stdio pipes");
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
@@ -136,8 +120,8 @@ function summarizeMalformedACPStdoutError(error: unknown): { type: string; messa
|
||||
}
|
||||
|
||||
export function createLoggedNdJsonStream(
|
||||
output: NodeWritableStream,
|
||||
input: NodeReadableStream,
|
||||
output: WritableStream<Uint8Array>,
|
||||
input: ReadableStream<Uint8Array>,
|
||||
options: { logger: Logger; provider: string },
|
||||
): ACPStream {
|
||||
const textEncoder = new TextEncoder();
|
||||
@@ -168,7 +152,7 @@ export function createLoggedNdJsonStream(
|
||||
}
|
||||
|
||||
try {
|
||||
const message: AnyMessage = JSON.parse(trimmedLine);
|
||||
const message = JSON.parse(trimmedLine) as AnyMessage;
|
||||
controller.enqueue(message);
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
@@ -645,8 +629,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
overlays: [launchEnv],
|
||||
}),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
assertChildWithPipes(child);
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
|
||||
const stderrChunks: string[] = [];
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
@@ -660,9 +643,13 @@ export class ACPAgentClient implements AgentClient {
|
||||
});
|
||||
});
|
||||
|
||||
if (!child.stdin || !child.stdout) {
|
||||
throw new Error(`${this.provider} ACP process did not expose stdio pipes`);
|
||||
}
|
||||
|
||||
const stream = createLoggedNdJsonStream(
|
||||
Writable.toWeb(child.stdin),
|
||||
Readable.toWeb(child.stdout),
|
||||
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
|
||||
{ logger: this.logger, provider: this.provider },
|
||||
);
|
||||
const connection = new ClientSideConnection(() => this.buildProbeClient(), stream);
|
||||
@@ -1559,8 +1546,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
overlays: [this.launchEnv],
|
||||
}),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
assertChildWithPipes(child);
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
|
||||
const stderrChunks: string[] = [];
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
@@ -1582,9 +1568,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
});
|
||||
|
||||
if (!child.stdin || !child.stdout) {
|
||||
throw new Error(`${this.provider} ACP process did not expose stdio pipes`);
|
||||
}
|
||||
|
||||
const stream = createLoggedNdJsonStream(
|
||||
Writable.toWeb(child.stdin),
|
||||
Readable.toWeb(child.stdout),
|
||||
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
|
||||
{ logger: this.logger, provider: this.provider },
|
||||
);
|
||||
const connection = new ClientSideConnection(() => this, stream);
|
||||
@@ -1640,7 +1630,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
private warnInvalidSelection(value: string, message: string): void {
|
||||
this.logger.warn({ value }, message);
|
||||
(this.logger.warn as unknown as (value: string, message: string) => void)(value, message);
|
||||
}
|
||||
|
||||
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
|
||||
@@ -2420,7 +2410,9 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void {
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
return isRecord(value) ? value : null;
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown> | null, keys: string[]): string | undefined {
|
||||
@@ -2481,7 +2473,7 @@ function stringifyUnknown(value: unknown): string | undefined {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return typeof value === "bigint" ? String(value) : "[unserializable]";
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { ACPAgentSession } from "./acp-agent.js";
|
||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||
|
||||
const smokeSelection = new Set(
|
||||
(process.env.ACP_SMOKE ?? "")
|
||||
@@ -118,7 +117,7 @@ function createSmokeLogger(): SmokeLogger {
|
||||
error: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
};
|
||||
return asInternals<SmokeLogger>(logger);
|
||||
return logger as unknown as SmokeLogger;
|
||||
}
|
||||
|
||||
function createTrace(config: WrapperSmokeConfig): SmokeTrace {
|
||||
@@ -366,7 +365,7 @@ async function bootSession(
|
||||
}
|
||||
|
||||
async function captureFinalState(session: ACPAgentSession, trace: SmokeTrace): Promise<void> {
|
||||
const internals = asInternals<SessionInternals>(session);
|
||||
const internals = session as unknown as SessionInternals;
|
||||
trace.finalState = {
|
||||
runtime: await session.getRuntimeInfo(),
|
||||
availableModes: await session.getAvailableModes(),
|
||||
@@ -377,7 +376,7 @@ async function captureFinalState(session: ACPAgentSession, trace: SmokeTrace): P
|
||||
}
|
||||
|
||||
function getModelIds(session: ACPAgentSession): string[] {
|
||||
const internals = asInternals<SessionInternals>(session);
|
||||
const internals = session as unknown as SessionInternals;
|
||||
return internals.availableModels?.map((model) => model.modelId) ?? [];
|
||||
}
|
||||
|
||||
@@ -385,7 +384,7 @@ function getSelectOption(
|
||||
session: ACPAgentSession,
|
||||
category: string,
|
||||
): Extract<SessionConfigOption, { type: "select" }> | null {
|
||||
const internals = asInternals<SessionInternals>(session);
|
||||
const internals = session as unknown as SessionInternals;
|
||||
return (
|
||||
internals.configOptions.find(
|
||||
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
|
||||
@@ -516,7 +515,7 @@ for (const config of wrappers) {
|
||||
|
||||
const modeAfterSuccess = await session.getCurrentMode();
|
||||
originalSessionId = session.id;
|
||||
asInternals<{ sessionId: string }>(session).sessionId = `${originalSessionId}-missing`;
|
||||
(session as unknown as { sessionId: string }).sessionId = `${originalSessionId}-missing`;
|
||||
await expect(session.setMode(mode.id)).rejects.toThrow();
|
||||
expect(await session.getCurrentMode()).toBe(modeAfterSuccess);
|
||||
trace.notes.push(
|
||||
@@ -524,7 +523,7 @@ for (const config of wrappers) {
|
||||
);
|
||||
} finally {
|
||||
if (session && originalSessionId) {
|
||||
asInternals<{ sessionId: string }>(session).sessionId = originalSessionId;
|
||||
(session as unknown as { sessionId: string }).sessionId = originalSessionId;
|
||||
}
|
||||
await captureFinalStateIfOpen(session, trace);
|
||||
emitEvidence(`${config.id}:b`, trace);
|
||||
@@ -623,7 +622,7 @@ for (const config of wrappers) {
|
||||
expect(
|
||||
trace.notifications.some((entry) => JSON.stringify(entry).includes("tool_call_update")),
|
||||
).toBe(true);
|
||||
expect(asInternals<SessionInternals>(session).toolCalls.size).toBeGreaterThan(0);
|
||||
expect((session as unknown as SessionInternals).toolCalls.size).toBeGreaterThan(0);
|
||||
trace.criteria.toolSnapshot = {
|
||||
outcome: "PASS",
|
||||
detail:
|
||||
|
||||
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||
import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
@@ -125,7 +124,7 @@ function createSpyLogger(): {
|
||||
loggerLike.child.mockReturnValue(loggerLike);
|
||||
|
||||
return {
|
||||
logger: asInternals<Logger>(loggerLike),
|
||||
logger: loggerLike as unknown as Logger,
|
||||
calls,
|
||||
debug,
|
||||
info,
|
||||
@@ -262,7 +261,7 @@ test("logs redacted query summary and never leaks sentinel secrets", async () =>
|
||||
test("interruptActiveTurn only interrupts the active query without info logs", async () => {
|
||||
const spy = createSpyLogger();
|
||||
const session = await createSessionWithLogger(spy.logger);
|
||||
const internal: {
|
||||
const internal = session as unknown as {
|
||||
query: {
|
||||
interrupt: () => Promise<void>;
|
||||
return?: () => Promise<void>;
|
||||
@@ -271,7 +270,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a
|
||||
input: { end: () => void } | null;
|
||||
queryRestartNeeded: boolean;
|
||||
interruptActiveTurn: () => Promise<void>;
|
||||
} = asInternals(session);
|
||||
};
|
||||
const interrupt = vi.fn(async () => undefined);
|
||||
const queryReturn = vi.fn(async () => undefined);
|
||||
const end = vi.fn(() => undefined);
|
||||
@@ -384,7 +383,7 @@ test("extracts identifiers from fixture-driven protocol shape variants", () => {
|
||||
for (const fixture of fixtures) {
|
||||
expect(
|
||||
readEventIdentifiers(
|
||||
asInternals<Parameters<typeof readEventIdentifiers>[0]>(fixture.message),
|
||||
fixture.message as unknown as Parameters<typeof readEventIdentifiers>[0],
|
||||
),
|
||||
).toEqual(fixture.expected);
|
||||
}
|
||||
@@ -412,12 +411,12 @@ test("captures session IDs from fixture-driven init message variants", async ()
|
||||
await Promise.all(
|
||||
fixtures.map(async (fixture) => {
|
||||
const session = await createSession();
|
||||
const internal: {
|
||||
const internal = session as unknown as {
|
||||
handleSystemMessage: (message: Record<string, unknown>) => {
|
||||
threadStartedSessionId: string | null;
|
||||
notice: AgentTimelineItem | null;
|
||||
};
|
||||
} = asInternals(session);
|
||||
};
|
||||
try {
|
||||
const started = internal.handleSystemMessage({
|
||||
type: "system",
|
||||
@@ -440,12 +439,12 @@ test("captures session IDs from fixture-driven init message variants", async ()
|
||||
|
||||
test("waits for complete JSON values before updating tool input from input_json_delta", async () => {
|
||||
const session = await createSession();
|
||||
const internal: {
|
||||
const internal = session as unknown as {
|
||||
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
|
||||
toolUseCache: Map<string, { input?: Record<string, unknown> }>;
|
||||
toolUseIndexToId: Map<number, string>;
|
||||
toolUseInputBuffers: Map<string, string>;
|
||||
} = asInternals(session);
|
||||
};
|
||||
|
||||
const toolUseId = "tool-input-delta";
|
||||
const index = 7;
|
||||
@@ -502,7 +501,7 @@ test("waits for complete JSON values before updating tool input from input_json_
|
||||
] as const;
|
||||
|
||||
for (const fixture of deltaFixtures) {
|
||||
internal.mapPartialEvent(asInternals<Record<string, unknown>>(fixture.event));
|
||||
internal.mapPartialEvent(fixture.event as unknown as Record<string, unknown>);
|
||||
expect(readCommand()).toBe(fixture.expectedCommand);
|
||||
}
|
||||
|
||||
@@ -519,10 +518,10 @@ test("waits for complete JSON values before updating tool input from input_json_
|
||||
|
||||
test("does not surface incomplete string values from input_json_delta", async () => {
|
||||
const session = await createSession();
|
||||
const internal: {
|
||||
const internal = session as unknown as {
|
||||
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
|
||||
toolUseCache: Map<string, { input?: Record<string, unknown> }>;
|
||||
} = asInternals(session);
|
||||
};
|
||||
|
||||
const toolUseId = "tool-input-preview";
|
||||
const index = 8;
|
||||
@@ -556,12 +555,12 @@ test("does not surface incomplete string values from input_json_delta", async ()
|
||||
|
||||
test("maps tool_result content shapes into deterministic string output", async () => {
|
||||
const session = await createSession();
|
||||
const internal: {
|
||||
const internal = session as unknown as {
|
||||
buildToolOutput: (
|
||||
block: Record<string, unknown>,
|
||||
entry: Record<string, unknown> | undefined,
|
||||
) => Record<string, unknown> | undefined;
|
||||
} = asInternals(session);
|
||||
};
|
||||
|
||||
const toolEntry = {
|
||||
id: "tool-1",
|
||||
@@ -629,12 +628,12 @@ test("maps tool_result content shapes into deterministic string output", async (
|
||||
|
||||
test("Grep tool_result string content flows to a search detail with content", async () => {
|
||||
const session = await createSession();
|
||||
const internal: {
|
||||
const internal = session as unknown as {
|
||||
buildToolOutput: (
|
||||
block: Record<string, unknown>,
|
||||
entry: Record<string, unknown> | undefined,
|
||||
) => Record<string, unknown> | undefined;
|
||||
} = asInternals(session);
|
||||
};
|
||||
|
||||
const grepEntry = {
|
||||
id: "tool-grep-1",
|
||||
@@ -891,13 +890,13 @@ test("plan approval exposes a resume-bypass action and can return to bypassPermi
|
||||
await session.setMode("bypassPermissions");
|
||||
await session.setMode("plan");
|
||||
|
||||
const internal: {
|
||||
const internal = session as unknown as {
|
||||
handlePermissionRequest: (
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
options: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
} = asInternals(session);
|
||||
};
|
||||
|
||||
const pendingResolution = internal.handlePermissionRequest(
|
||||
"ExitPlanMode",
|
||||
@@ -957,12 +956,12 @@ test("plan approval exposes a resume-bypass action and can return to bypassPermi
|
||||
|
||||
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
|
||||
const session = await createSession();
|
||||
const internal: {
|
||||
const internal = session as unknown as {
|
||||
turnState: "idle" | "foreground" | "autonomous";
|
||||
nextTurnOrdinal: number;
|
||||
routeSdkMessageFromPump: (message: Record<string, unknown>) => void;
|
||||
autonomousTurn: { id: string } | null;
|
||||
} = asInternals(session);
|
||||
};
|
||||
|
||||
internal.turnState = "idle";
|
||||
internal.routeSdkMessageFromPump({
|
||||
@@ -1353,9 +1352,9 @@ test("does not use stream_event uuid as assistant message identity when message_
|
||||
|
||||
expect(assistantText).toContain("HELLO WORLD");
|
||||
|
||||
const assembler: {
|
||||
const assembler = session as unknown as {
|
||||
timelineAssembler: { messages: Map<string, unknown> };
|
||||
} = asInternals(session);
|
||||
};
|
||||
expect(assembler.timelineAssembler.messages.size).toBe(0);
|
||||
|
||||
await session.close();
|
||||
|
||||
@@ -2,11 +2,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import {
|
||||
ClaudeAgentClient,
|
||||
convertClaudeHistoryEntry,
|
||||
normalizeClaudeAskUserQuestionUpdatedInput,
|
||||
} from "./claude-agent.js";
|
||||
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
||||
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
interface TestClaudeSession {
|
||||
@@ -368,138 +364,6 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
||||
test("maps frontend header-keyed answers to Claude question text keys", () => {
|
||||
expect(
|
||||
normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
{
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider should I use?",
|
||||
header: "Provider",
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
answers: { Provider: "Claude" },
|
||||
},
|
||||
undefined,
|
||||
),
|
||||
).toEqual({
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider should I use?",
|
||||
header: "Provider",
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
answers: { "Which provider should I use?": "Claude" },
|
||||
});
|
||||
});
|
||||
|
||||
test("uses fallback request questions when response only includes answers", () => {
|
||||
expect(
|
||||
normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
{
|
||||
answers: { Provider: "Codex" },
|
||||
},
|
||||
{
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider should I use?",
|
||||
header: "Provider",
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider should I use?",
|
||||
header: "Provider",
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
answers: { "Which provider should I use?": "Codex" },
|
||||
});
|
||||
});
|
||||
|
||||
test("respondToPermission preserves full question input when UI returns answers-only payload", async () => {
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const request = {
|
||||
id: "permission-question-1",
|
||||
provider: "claude",
|
||||
name: "AskUserQuestion",
|
||||
kind: "question",
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider should I use?",
|
||||
header: "Provider",
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const resultPromise = new Promise<unknown>((resolve, reject) => {
|
||||
(
|
||||
session as unknown as {
|
||||
pendingPermissions: Map<
|
||||
string,
|
||||
{
|
||||
request: typeof request;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>;
|
||||
}
|
||||
).pendingPermissions.set(request.id, {
|
||||
request,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await session.respondToPermission(request.id, {
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
answers: { Provider: "Claude" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider should I use?",
|
||||
header: "Provider",
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
answers: { "Which provider should I use?": "Claude" },
|
||||
},
|
||||
updatedPermissions: undefined,
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeAgentSession context window usage", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
|
||||
@@ -87,61 +87,6 @@ import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js
|
||||
const fsPromises = promises;
|
||||
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
updatedInput: AgentMetadata | undefined,
|
||||
fallbackInput: AgentMetadata | undefined,
|
||||
): AgentMetadata {
|
||||
const fallback = isMetadata(fallbackInput) ? fallbackInput : {};
|
||||
const base = isMetadata(updatedInput) ? updatedInput : {};
|
||||
// Paseo's shared question UI serializes answers by question header, but Claude's
|
||||
// AskUserQuestion tool expects answer keys to match the full question text. Merge
|
||||
// the original request payload back in so provider callbacks that only return
|
||||
// `{ answers }` still satisfy Claude's full tool input schema.
|
||||
const merged = { ...fallback, ...base };
|
||||
const questions =
|
||||
(Array.isArray(base.questions) ? base.questions : null) ??
|
||||
(Array.isArray(fallback.questions) ? fallback.questions : null);
|
||||
const answers = isMetadata(base.answers) ? base.answers : null;
|
||||
|
||||
if (!questions || !answers) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
const normalizedAnswers: Record<string, string> = {};
|
||||
for (const item of questions) {
|
||||
const question = isMetadata(item) ? item : null;
|
||||
if (!question) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const questionText = readNonEmptyString(question.question);
|
||||
if (!questionText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const header = readNonEmptyString(question.header);
|
||||
const answer =
|
||||
readNonEmptyString(answers[questionText]) ??
|
||||
(header ? readNonEmptyString(answers[header]) : null);
|
||||
if (answer) {
|
||||
normalizedAnswers[questionText] = answer;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(normalizedAnswers).length === 0) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
return {
|
||||
...merged,
|
||||
answers: normalizedAnswers,
|
||||
};
|
||||
}
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -1860,16 +1805,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}),
|
||||
);
|
||||
}
|
||||
const updatedInput =
|
||||
pending.request.kind === "question"
|
||||
? normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
response.updatedInput,
|
||||
pending.request.input ?? undefined,
|
||||
)
|
||||
: (response.updatedInput ?? pending.request.input ?? {});
|
||||
const result: PermissionResult = {
|
||||
behavior: "allow",
|
||||
updatedInput,
|
||||
updatedInput: response.updatedInput ?? pending.request.input ?? {},
|
||||
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
|
||||
};
|
||||
pending.resolve(result);
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
codexAppServerTurnInputFromPrompt,
|
||||
} from "./codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
|
||||
|
||||
interface CollaborationModeRecord {
|
||||
name: string;
|
||||
@@ -72,7 +71,7 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}): Codex
|
||||
() => {
|
||||
throw new Error("Test session cannot spawn Codex app-server");
|
||||
},
|
||||
) as CodexTestSession;
|
||||
) as unknown as CodexTestSession;
|
||||
session.connected = true;
|
||||
session.currentThreadId = "test-thread";
|
||||
session.activeForegroundTurnId = "test-turn";
|
||||
@@ -80,7 +79,7 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}): Codex
|
||||
}
|
||||
|
||||
function asInternals(session: CodexTestSession): CodexSessionTestAccess {
|
||||
return castInternals<CodexSessionTestAccess>(session);
|
||||
return session as unknown as CodexSessionTestAccess;
|
||||
}
|
||||
|
||||
describe("Codex app-server provider", () => {
|
||||
@@ -106,9 +105,9 @@ describe("Codex app-server provider", () => {
|
||||
{},
|
||||
true,
|
||||
);
|
||||
castInternals<{ client: CodexClientLike }>(session).client = fakeClient;
|
||||
(session as unknown as { client: CodexClientLike }).client = fakeClient;
|
||||
|
||||
await castInternals<{ ensureThread: () => Promise<void> }>(session).ensureThread();
|
||||
await (session as unknown as { ensureThread: () => Promise<void> }).ensureThread();
|
||||
|
||||
const startCall = requests.find((req) => req.method === "thread/start");
|
||||
expect(startCall).toBeDefined();
|
||||
@@ -135,9 +134,9 @@ describe("Codex app-server provider", () => {
|
||||
throw new Error("Test session cannot spawn Codex app-server");
|
||||
},
|
||||
);
|
||||
castInternals<{ client: CodexClientLike }>(session).client = fakeClient;
|
||||
(session as unknown as { client: CodexClientLike }).client = fakeClient;
|
||||
|
||||
await castInternals<{ ensureThread: () => Promise<void> }>(session).ensureThread();
|
||||
await (session as unknown as { ensureThread: () => Promise<void> }).ensureThread();
|
||||
|
||||
const startCall = requests.find((req) => req.method === "thread/start");
|
||||
expect(startCall).toBeDefined();
|
||||
@@ -350,7 +349,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({ request });
|
||||
session.client = { request } as unknown as CodexClientLike;
|
||||
|
||||
await session.startTurn("Return JSON", {
|
||||
outputSchema: {
|
||||
@@ -406,7 +405,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({ request });
|
||||
session.client = { request } as unknown as CodexClientLike;
|
||||
|
||||
await session.startTurn("/paseo-implement in a worktree, remember to use Claude for the UI");
|
||||
|
||||
@@ -1440,7 +1439,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({ request });
|
||||
session.client = { request } as unknown as CodexClientLike;
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
@@ -30,7 +30,7 @@ import type {
|
||||
import type { Logger } from "pino";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
@@ -65,18 +65,6 @@ import {
|
||||
import { runProviderTurn } from "./provider-runner.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
|
||||
function assertChildWithPipes(
|
||||
child: ChildProcess,
|
||||
): asserts child is ChildProcessWithoutNullStreams {
|
||||
if (!child.stdin || !child.stdout || !child.stderr) {
|
||||
throw new Error("Child process did not expose stdio pipes");
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||
const INTERRUPT_TIMEOUT_MS = 2_000;
|
||||
@@ -576,24 +564,29 @@ interface JsonRpcNotification {
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
function isJsonRpcResponse(msg: unknown): msg is JsonRpcResponse {
|
||||
if (!isRecord(msg)) return false;
|
||||
if (typeof msg.id !== "number") return false;
|
||||
return msg.result !== undefined || !!msg.error;
|
||||
type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
|
||||
|
||||
function isJsonRpcResponse(msg: JsonRpcMessage): msg is JsonRpcResponse {
|
||||
const m = msg as unknown as Record<string, unknown>;
|
||||
if (typeof m.id !== "number") return false;
|
||||
return m.result !== undefined || !!m.error;
|
||||
}
|
||||
|
||||
function isJsonRpcRequest(msg: unknown): msg is JsonRpcRequest {
|
||||
if (!isRecord(msg)) return false;
|
||||
return typeof msg.id === "number" && typeof msg.method === "string";
|
||||
function isJsonRpcRequest(msg: JsonRpcMessage): msg is JsonRpcRequest {
|
||||
const m = msg as unknown as Record<string, unknown>;
|
||||
return typeof m.id === "number" && typeof m.method === "string";
|
||||
}
|
||||
|
||||
function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
|
||||
if (!isRecord(msg)) return false;
|
||||
return typeof msg.method === "string" && typeof msg.id !== "number";
|
||||
function isJsonRpcNotification(msg: JsonRpcMessage): msg is JsonRpcNotification {
|
||||
const m = msg as unknown as Record<string, unknown>;
|
||||
return typeof m.method === "string" && typeof m.id !== "number";
|
||||
}
|
||||
|
||||
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isRecord(value) ? value : undefined;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
@@ -622,28 +615,9 @@ interface CodexModel {
|
||||
supportedReasoningEfforts?: CodexReasoningEffortEntry[];
|
||||
}
|
||||
|
||||
const CodexModelListResponseSchema = z.object({
|
||||
data: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
displayName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
model: z.string().optional(),
|
||||
defaultReasoningEffort: z.string().optional(),
|
||||
supportedReasoningEfforts: z
|
||||
.array(
|
||||
z.object({
|
||||
reasoningEffort: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
interface CodexModelListResponse {
|
||||
data?: CodexModel[];
|
||||
}
|
||||
|
||||
class CodexAppServerClient {
|
||||
private readonly rl: readline.Interface;
|
||||
@@ -766,30 +740,31 @@ class CodexAppServerClient {
|
||||
|
||||
private async handleLine(line: string): Promise<void> {
|
||||
if (!line.trim()) return;
|
||||
const raw: unknown = JSON.parse(line);
|
||||
if (!isRecord(raw)) {
|
||||
const raw = JSON.parse(line);
|
||||
const msg = toObjectRecord(raw) as unknown as JsonRpcMessage;
|
||||
if (!msg) {
|
||||
this.logger.warn({ line }, "Parsed JSON is not an object");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJsonRpcResponse(raw)) {
|
||||
const id = raw.id;
|
||||
if (raw.result !== undefined || raw.error) {
|
||||
if (isJsonRpcResponse(msg)) {
|
||||
const id = msg.id;
|
||||
if (msg.result !== undefined || msg.error) {
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
if (raw.error) {
|
||||
pending.reject(new Error(raw.error.message ?? "Unknown error"));
|
||||
if (msg.error) {
|
||||
pending.reject(new Error(msg.error.message ?? "Unknown error"));
|
||||
} else {
|
||||
pending.resolve(raw.result);
|
||||
pending.resolve(msg.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Server-initiated request
|
||||
if (isJsonRpcRequest(raw)) {
|
||||
const request = raw;
|
||||
if (isJsonRpcRequest(msg)) {
|
||||
const request = msg;
|
||||
const handler = this.requestHandlers.get(request.method);
|
||||
try {
|
||||
const result = handler ? await handler(request.params) : {};
|
||||
@@ -804,8 +779,8 @@ class CodexAppServerClient {
|
||||
}
|
||||
}
|
||||
|
||||
if (isJsonRpcNotification(raw)) {
|
||||
this.notificationHandler?.(raw.method, raw.params);
|
||||
if (isJsonRpcNotification(msg)) {
|
||||
this.notificationHandler?.(msg.method, msg.params);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4347,16 +4322,14 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private handleCommandApprovalRequest(params: unknown): Promise<unknown> {
|
||||
const parsed = z
|
||||
.object({
|
||||
itemId: z.string(),
|
||||
threadId: z.string(),
|
||||
turnId: z.string(),
|
||||
command: z.string().nullable().optional(),
|
||||
cwd: z.string().nullable().optional(),
|
||||
reason: z.string().nullable().optional(),
|
||||
})
|
||||
.parse(params);
|
||||
const parsed = params as {
|
||||
itemId: string;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
command?: string | null;
|
||||
cwd?: string | null;
|
||||
reason?: string | null;
|
||||
};
|
||||
const commandPreview = mapCodexExecNotificationToToolCall({
|
||||
callId: parsed.itemId,
|
||||
command: parsed.command,
|
||||
@@ -4398,14 +4371,12 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private handleFileChangeApprovalRequest(params: unknown): Promise<unknown> {
|
||||
const parsed = z
|
||||
.object({
|
||||
itemId: z.string(),
|
||||
threadId: z.string(),
|
||||
turnId: z.string(),
|
||||
reason: z.string().nullable().optional(),
|
||||
})
|
||||
.parse(params);
|
||||
const parsed = params as {
|
||||
itemId: string;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
reason?: string | null;
|
||||
};
|
||||
const requestId = `permission-${parsed.itemId}`;
|
||||
const request: AgentPermissionRequest = {
|
||||
id: requestId,
|
||||
@@ -4435,14 +4406,12 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private handleToolApprovalRequest(params: unknown): Promise<unknown> {
|
||||
const parsed = z
|
||||
.object({
|
||||
itemId: z.string(),
|
||||
threadId: z.string(),
|
||||
turnId: z.string(),
|
||||
questions: z.array(z.unknown()),
|
||||
})
|
||||
.parse(params);
|
||||
const parsed = params as {
|
||||
itemId: string;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
questions: unknown[];
|
||||
};
|
||||
const requestId = `permission-${parsed.itemId}`;
|
||||
const questions = normalizeCodexQuestionPrompts(parsed.questions);
|
||||
const request: AgentPermissionRequest = {
|
||||
@@ -4506,16 +4475,14 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
},
|
||||
"Spawning Codex app server",
|
||||
);
|
||||
const child = spawnProcess(launchPrefix.command, [...launchPrefix.args, "app-server"], {
|
||||
return spawnProcess(launchPrefix.command, [...launchPrefix.args, "app-server"], {
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
}),
|
||||
});
|
||||
assertChildWithPipes(child);
|
||||
return child;
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -4541,7 +4508,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const storedConfig = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
const storedConfig = (handle.metadata ?? {}) as unknown as AgentSessionConfig;
|
||||
const merged: AgentSessionConfig = {
|
||||
...storedConfig,
|
||||
...overrides,
|
||||
@@ -4632,9 +4599,8 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
await client.request("initialize", buildCodexAppServerInitializeParams());
|
||||
client.notify("initialized", {});
|
||||
|
||||
const rawResponse = await client.request("model/list", {});
|
||||
const parsedResponse = CodexModelListResponseSchema.safeParse(rawResponse);
|
||||
const models = parsedResponse.success ? (parsedResponse.data.data ?? []) : [];
|
||||
const response = (await client.request("model/list", {})) as CodexModelListResponse;
|
||||
const models = Array.isArray(response?.data) ? response.data : [];
|
||||
const configuredDefaults = await readCodexConfiguredDefaults(client, this.logger);
|
||||
const configuredDefaultModelId = configuredDefaults.model;
|
||||
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
|
||||
|
||||
@@ -12,9 +12,9 @@ export type ProviderFinalTextReducer = (params: {
|
||||
}) => string;
|
||||
|
||||
export interface ProviderTurnRunner {
|
||||
startTurn: (prompt: AgentPromptInput, options?: AgentRunOptions) => Promise<{ turnId: string }>;
|
||||
subscribe: (callback: (event: AgentStreamEvent) => void) => () => void;
|
||||
getSessionId: () => string | Promise<string>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
getSessionId(): string | Promise<string>;
|
||||
}
|
||||
|
||||
export interface RunProviderTurnOptions extends ProviderTurnRunner {
|
||||
|
||||
@@ -126,7 +126,6 @@ import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import type { PersistedConfig } from "./persisted-config.js";
|
||||
import {
|
||||
ScriptRouteStore,
|
||||
createScriptProxyMiddleware,
|
||||
@@ -202,7 +201,6 @@ export interface PaseoDaemonConfig {
|
||||
downloadTokenTtlMs?: number;
|
||||
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
||||
providerOverrides?: Record<string, ProviderOverride>;
|
||||
log?: PersistedConfig["log"];
|
||||
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
|
||||
}
|
||||
|
||||
@@ -842,6 +840,16 @@ export async function createPaseoDaemon(
|
||||
github,
|
||||
);
|
||||
|
||||
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
|
||||
process.send({
|
||||
type: "paseo:ready",
|
||||
listen:
|
||||
boundListenTarget.type === "tcp"
|
||||
? `${boundListenTarget.host}:${boundListenTarget.port}`
|
||||
: boundListenTarget.path,
|
||||
});
|
||||
}
|
||||
|
||||
if (relayEnabled) {
|
||||
const offer = await createConnectionOfferV2({
|
||||
serverId,
|
||||
|
||||
@@ -3,12 +3,7 @@ import { resolvePaseoNodeEnv } from "./paseo-env.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { PaseoDaemonConfig } from "./bootstrap.js";
|
||||
import {
|
||||
loadPersistedConfig,
|
||||
LogFormatSchema,
|
||||
LogLevelSchema,
|
||||
type PersistedConfig,
|
||||
} from "./persisted-config.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
@@ -40,14 +35,6 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeLogEnv(value: string | undefined): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export type CliConfigOverrides = Partial<{
|
||||
listen: string;
|
||||
relayEnabled: boolean;
|
||||
@@ -56,24 +43,6 @@ export type CliConfigOverrides = Partial<{
|
||||
hostnames: HostnamesConfig;
|
||||
}>;
|
||||
|
||||
function resolveLogConfigFromEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||
): PersistedConfig["log"] {
|
||||
const envLogLevel = LogLevelSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_LEVEL));
|
||||
const envLogFormat = LogFormatSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_FORMAT));
|
||||
|
||||
if (!envLogLevel.success && !envLogFormat.success) {
|
||||
return persisted.log;
|
||||
}
|
||||
|
||||
return {
|
||||
...persisted.log,
|
||||
...(envLogLevel.success ? { level: envLogLevel.data } : {}),
|
||||
...(envLogFormat.success ? { format: envLogFormat.data } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const OptionalVoiceLlmProviderSchema = z
|
||||
.union([z.string(), z.null(), z.undefined()])
|
||||
.transform((value): string | null =>
|
||||
@@ -302,6 +271,5 @@ export function loadConfig(
|
||||
voiceLlmModel: voiceLlm.model,
|
||||
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
||||
providerOverrides,
|
||||
log: resolveLogConfigFromEnv(env, persisted),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,8 +134,6 @@ export function isProviderAvailable(provider: AgentProvider): Promise<boolean> {
|
||||
Boolean(process.env.OPENROUTER_API_KEY) ||
|
||||
existsSync(join(homedir(), ".pi", "agent", "auth.json")))
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import os from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { Writable } from "node:stream";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { generateLocalPairingOffer } from "../pairing-offer.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
@@ -201,9 +202,8 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
||||
const tempHome = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-e2e-"));
|
||||
const port = await getAvailablePort();
|
||||
|
||||
const serverRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const supervisorPath = path.join(serverRoot, "scripts/supervisor-entrypoint.ts");
|
||||
const tsxBin = path.resolve(serverRoot, "../../node_modules/.bin/tsx");
|
||||
const indexPath = fileURLToPath(new URL("../index.ts", import.meta.url));
|
||||
const tsxBin = path.resolve(process.cwd(), "../../node_modules/.bin/tsx");
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
@@ -216,7 +216,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
||||
};
|
||||
|
||||
const stdoutLines: string[] = [];
|
||||
const proc = spawn(tsxBin, [supervisorPath, "--dev", "--no-relay"], {
|
||||
const proc = spawn(tsxBin, [indexPath, "--no-relay"], {
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -11,8 +11,6 @@ import { generateLocalPairingOffer } from "../pairing-offer.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
|
||||
import { buildRelayWebSocketUrl } from "../../shared/daemon-endpoints.js";
|
||||
import { ConnectionOfferSchema } from "../../shared/connection-offer.js";
|
||||
import { WSOutboundMessageSchema } from "../../shared/messages.js";
|
||||
|
||||
const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0");
|
||||
const shouldRunRelayE2e = process.env.FORCE_RELAY_E2E === "1" || nodeMajor < 25;
|
||||
@@ -61,7 +59,10 @@ function decodeOfferFromFragmentUrl(url: string): {
|
||||
}
|
||||
const encoded = url.slice(idx + marker.length);
|
||||
const json = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
const offer = ConnectionOfferSchema.parse(JSON.parse(json));
|
||||
const offer = JSON.parse(json) as { v?: unknown; serverId?: string; daemonPublicKeyB64?: string };
|
||||
if (offer.v !== 2) throw new Error("expected offer.v=2");
|
||||
if (!offer.serverId) throw new Error("offer.serverId missing");
|
||||
if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
|
||||
return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 };
|
||||
}
|
||||
|
||||
@@ -281,12 +282,27 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
onmessage: (data) => {
|
||||
try {
|
||||
const payload = typeof data === "string" ? JSON.parse(data) : data;
|
||||
const wsMsg = WSOutboundMessageSchema.safeParse(payload);
|
||||
if (
|
||||
wsMsg.success &&
|
||||
wsMsg.data.type === "session" &&
|
||||
wsMsg.data.message.type === "status" &&
|
||||
wsMsg.data.message.payload?.status === "server_info"
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
(
|
||||
payload as {
|
||||
type?: string;
|
||||
message?: { type?: string; payload?: { status?: string } };
|
||||
}
|
||||
).type === "session" &&
|
||||
(
|
||||
payload as {
|
||||
type?: string;
|
||||
message?: { type?: string; payload?: { status?: string } };
|
||||
}
|
||||
).message?.type === "status" &&
|
||||
(
|
||||
payload as {
|
||||
type?: string;
|
||||
message?: { type?: string; payload?: { status?: string } };
|
||||
}
|
||||
).message?.payload?.status === "server_info"
|
||||
) {
|
||||
if (!pingSent && channelRef) {
|
||||
pingSent = true;
|
||||
@@ -294,8 +310,17 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (wsMsg.success && wsMsg.data.type === "pong") {
|
||||
settleResolve(wsMsg.data);
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
(
|
||||
payload as {
|
||||
type?: string;
|
||||
message?: { type?: string; payload?: { status?: string } };
|
||||
}
|
||||
).type === "pong"
|
||||
) {
|
||||
settleResolve(payload);
|
||||
ws.close();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -407,12 +432,27 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
const channel = await createClientChannel(transport, daemonPublicKeyB64, {
|
||||
onmessage: (data) => {
|
||||
const payload = typeof data === "string" ? JSON.parse(data) : data;
|
||||
const wsMsg = WSOutboundMessageSchema.safeParse(payload);
|
||||
if (
|
||||
wsMsg.success &&
|
||||
wsMsg.data.type === "session" &&
|
||||
wsMsg.data.message.type === "status" &&
|
||||
wsMsg.data.message.payload?.status === "server_info"
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
(
|
||||
payload as {
|
||||
type?: string;
|
||||
message?: { type?: string; payload?: { status?: string } };
|
||||
}
|
||||
).type === "session" &&
|
||||
(
|
||||
payload as {
|
||||
type?: string;
|
||||
message?: { type?: string; payload?: { status?: string } };
|
||||
}
|
||||
).message?.type === "status" &&
|
||||
(
|
||||
payload as {
|
||||
type?: string;
|
||||
message?: { type?: string; payload?: { status?: string } };
|
||||
}
|
||||
).message?.payload?.status === "server_info"
|
||||
) {
|
||||
if (!pingSent && channelRef) {
|
||||
pingSent = true;
|
||||
@@ -420,9 +460,18 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (wsMsg.success && wsMsg.data.type === "pong") {
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
(
|
||||
payload as {
|
||||
type?: string;
|
||||
message?: { type?: string; payload?: { status?: string } };
|
||||
}
|
||||
).type === "pong"
|
||||
) {
|
||||
clearTimeout(timeout);
|
||||
resolve(wsMsg.data);
|
||||
resolve(payload);
|
||||
ws.close();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,16 +2,14 @@ import { createPaseoDaemon } from "./bootstrap.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { resolvePaseoHome } from "./paseo-home.js";
|
||||
import { createRootLogger } from "./logger.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
import { acquirePidLock, PidLockError, releasePidLock, updatePidLock } from "./pid-lock.js";
|
||||
import type { DaemonLifecycleIntent } from "./bootstrap.js";
|
||||
|
||||
type SupervisorLifecycleMessage =
|
||||
| {
|
||||
type: "paseo:shutdown";
|
||||
}
|
||||
| {
|
||||
type: "paseo:ready";
|
||||
listen: string;
|
||||
}
|
||||
| {
|
||||
type: "paseo:restart";
|
||||
reason?: string;
|
||||
@@ -26,8 +24,9 @@ interface BootstrapResult {
|
||||
function bootstrapFromEnvironment(): BootstrapResult {
|
||||
try {
|
||||
const paseoHome = resolvePaseoHome();
|
||||
const persistedConfig = loadPersistedConfig(paseoHome);
|
||||
const logger = createRootLogger(persistedConfig, { paseoHome });
|
||||
const config = loadConfig(paseoHome);
|
||||
const logger = createRootLogger({ log: config.log }, { paseoHome, file: false });
|
||||
return { paseoHome, logger, config };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -49,10 +48,12 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { logger, config } = bootstrapFromEnvironment();
|
||||
const { paseoHome, logger, config } = bootstrapFromEnvironment();
|
||||
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
|
||||
let shutdownPromise: Promise<number> | null = null;
|
||||
let exitHookInstalled = false;
|
||||
const supervised = process.env.PASEO_SUPERVISED === "1" && typeof process.send === "function";
|
||||
let pidLockAcquired = false;
|
||||
|
||||
applyCliFlagOverrides(config);
|
||||
|
||||
@@ -88,6 +89,10 @@ async function main() {
|
||||
return 1;
|
||||
}
|
||||
await daemon.stop();
|
||||
if (pidLockAcquired) {
|
||||
await releasePidLock(paseoHome);
|
||||
pidLockAcquired = false;
|
||||
}
|
||||
clearTimeout(forceExit);
|
||||
logger.info("Server closed");
|
||||
return options?.successExitCode ?? 0;
|
||||
@@ -146,6 +151,11 @@ async function main() {
|
||||
};
|
||||
|
||||
try {
|
||||
if (!supervised) {
|
||||
await acquirePidLock(paseoHome, null);
|
||||
pidLockAcquired = true;
|
||||
}
|
||||
|
||||
daemon = await createPaseoDaemon(
|
||||
{
|
||||
...config,
|
||||
@@ -154,22 +164,40 @@ async function main() {
|
||||
logger,
|
||||
);
|
||||
} catch (err) {
|
||||
if (pidLockAcquired) {
|
||||
await releasePidLock(paseoHome);
|
||||
pidLockAcquired = false;
|
||||
}
|
||||
if (err instanceof PidLockError) {
|
||||
logger.error({ pid: err.existingLock?.pid }, err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
logger.fatal({ err }, "Daemon bootstrap failed");
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await daemon.start();
|
||||
const listenTarget = daemon.getListenTarget();
|
||||
const listen =
|
||||
listenTarget?.type === "tcp"
|
||||
? `${listenTarget.host}:${listenTarget.port}`
|
||||
: listenTarget?.path;
|
||||
if (!listen) {
|
||||
throw new Error("Daemon did not expose a listen target after startup");
|
||||
if (!supervised) {
|
||||
const listenTarget = daemon.getListenTarget();
|
||||
const listen =
|
||||
listenTarget?.type === "tcp"
|
||||
? `${listenTarget.host}:${listenTarget.port}`
|
||||
: listenTarget?.path;
|
||||
if (!listen) {
|
||||
throw new Error("Daemon did not expose a listen target after startup");
|
||||
}
|
||||
await updatePidLock(paseoHome, { listen });
|
||||
}
|
||||
sendSupervisorLifecycleMessage({ type: "paseo:ready", listen });
|
||||
} catch (err) {
|
||||
if (pidLockAcquired) {
|
||||
await releasePidLock(paseoHome);
|
||||
pidLockAcquired = false;
|
||||
}
|
||||
if (err instanceof PidLockError) {
|
||||
logger.error({ pid: err.existingLock?.pid }, err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
logger.fatal({ err }, "Daemon failed to start listening");
|
||||
throw err;
|
||||
}
|
||||
@@ -1,82 +1,47 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { resolveLogConfig } from "./logger.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import type { PersistedConfig } from "./persisted-config.js";
|
||||
|
||||
const repoRoot = path.resolve(fileURLToPath(new URL("../../../..", import.meta.url)));
|
||||
const loggerModuleUrl = new URL("./logger.ts", import.meta.url).href;
|
||||
|
||||
async function runLoggerFixture(source: string): Promise<{ stdout: string; stderr: string }> {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), "paseo-logger-fixture-"));
|
||||
const runnerPath = path.join(tempDir, "runner.mjs");
|
||||
await writeFile(
|
||||
runnerPath,
|
||||
`
|
||||
import { createRootLogger } from ${JSON.stringify(loggerModuleUrl)};
|
||||
|
||||
${source}
|
||||
`,
|
||||
);
|
||||
|
||||
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
const code = await new Promise<number | null>((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("close", resolve);
|
||||
});
|
||||
|
||||
expect(code, stderr).toBe(0);
|
||||
return { stdout, stderr };
|
||||
}
|
||||
|
||||
describe("resolveLogConfig", () => {
|
||||
const originalEnv = process.env;
|
||||
const paseoHome = "/tmp/paseo-logger-tests";
|
||||
|
||||
it("defaults to stdout JSON without file logging", () => {
|
||||
const result = resolveLogConfig(undefined, { paseoHome });
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.PASEO_LOG;
|
||||
delete process.env.PASEO_LOG_FORMAT;
|
||||
delete process.env.PASEO_LOG_CONSOLE_LEVEL;
|
||||
delete process.env.PASEO_LOG_FILE_LEVEL;
|
||||
delete process.env.PASEO_LOG_FILE_PATH;
|
||||
delete process.env.PASEO_LOG_FILE_ROTATE_SIZE;
|
||||
delete process.env.PASEO_LOG_FILE_ROTATE_COUNT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("returns dual-sink defaults when no config or env vars", () => {
|
||||
const result = resolveLogConfig(undefined, { paseoHome });
|
||||
expect(result).toEqual({
|
||||
level: "info",
|
||||
level: "debug",
|
||||
console: {
|
||||
level: "info",
|
||||
format: "json",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy level and format as stdout configuration", () => {
|
||||
const result = resolveLogConfig({ level: "warn", format: "pretty" }, { paseoHome });
|
||||
|
||||
expect(result).toEqual({
|
||||
level: "warn",
|
||||
console: {
|
||||
level: "warn",
|
||||
format: "pretty",
|
||||
},
|
||||
file: {
|
||||
level: "debug",
|
||||
path: path.join(paseoHome, "daemon.log"),
|
||||
rotate: {
|
||||
maxSize: "10m",
|
||||
maxFiles: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("enables file output only when log.file is present", () => {
|
||||
it("uses config.json destination-specific values over defaults", () => {
|
||||
const config: PersistedConfig = {
|
||||
log: {
|
||||
console: {
|
||||
@@ -85,7 +50,7 @@ describe("resolveLogConfig", () => {
|
||||
},
|
||||
file: {
|
||||
level: "debug",
|
||||
path: "logs/programmatic.log",
|
||||
path: "/tmp/custom.log",
|
||||
rotate: {
|
||||
maxSize: "25m",
|
||||
maxFiles: 5,
|
||||
@@ -93,8 +58,9 @@ describe("resolveLogConfig", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = resolveLogConfig(config, { paseoHome });
|
||||
|
||||
expect(resolveLogConfig(config, { paseoHome })).toEqual({
|
||||
expect(result).toEqual({
|
||||
level: "debug",
|
||||
console: {
|
||||
level: "warn",
|
||||
@@ -102,98 +68,166 @@ describe("resolveLogConfig", () => {
|
||||
},
|
||||
file: {
|
||||
level: "debug",
|
||||
path: path.join(paseoHome, "logs", "programmatic.log"),
|
||||
path: "/tmp/custom.log",
|
||||
rotate: {
|
||||
maxSize: "25m",
|
||||
maxFiles: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadConfig logger config", () => {
|
||||
it("applies log format env at the config boundary", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-logger-config-"));
|
||||
const paseoHome = path.join(root, ".paseo");
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
JSON.stringify({ version: 1, log: { format: "json" } }),
|
||||
);
|
||||
it("uses env vars over config.json values", () => {
|
||||
process.env.PASEO_LOG_CONSOLE_LEVEL = "error";
|
||||
process.env.PASEO_LOG_FILE_LEVEL = "fatal";
|
||||
process.env.PASEO_LOG_FORMAT = "json";
|
||||
process.env.PASEO_LOG_FILE_PATH = "logs/daemon-custom.log";
|
||||
process.env.PASEO_LOG_FILE_ROTATE_SIZE = "15m";
|
||||
process.env.PASEO_LOG_FILE_ROTATE_COUNT = "4";
|
||||
|
||||
const config = loadConfig(paseoHome, {
|
||||
env: { PASEO_LOG_FORMAT: "pretty" },
|
||||
const config: PersistedConfig = {
|
||||
log: {
|
||||
console: {
|
||||
level: "info",
|
||||
format: "pretty",
|
||||
},
|
||||
file: {
|
||||
level: "trace",
|
||||
path: "/tmp/will-be-overridden.log",
|
||||
rotate: {
|
||||
maxSize: "30m",
|
||||
maxFiles: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveLogConfig(config, { paseoHome });
|
||||
expect(result).toEqual({
|
||||
level: "error",
|
||||
console: {
|
||||
level: "error",
|
||||
format: "json",
|
||||
},
|
||||
file: {
|
||||
level: "fatal",
|
||||
path: path.resolve(paseoHome, "logs/daemon-custom.log"),
|
||||
rotate: {
|
||||
maxSize: "15m",
|
||||
maxFiles: 4,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(config.log?.format).toBe("pretty");
|
||||
expect(resolveLogConfig(config, { paseoHome }).console.format).toBe("pretty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRootLogger", () => {
|
||||
it("writes JSON to stdout by default and does not initialize file logging", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-default-"));
|
||||
const missingLogDir = path.join(paseoHome, "logs");
|
||||
|
||||
const { stdout } = await runLoggerFixture(`
|
||||
const logger = createRootLogger(undefined, { paseoHome: ${JSON.stringify(paseoHome)} });
|
||||
logger.info({ proof: "stdout-default" }, "default logger");
|
||||
logger.flush();
|
||||
`);
|
||||
it("keeps backwards compatibility for legacy log.level and log.format", () => {
|
||||
const config: PersistedConfig = {
|
||||
log: {
|
||||
level: "warn",
|
||||
format: "json",
|
||||
},
|
||||
};
|
||||
|
||||
expect(stdout).toContain('"proof":"stdout-default"');
|
||||
expect(stdout).toContain('"msg":"default logger"');
|
||||
expect(existsSync(path.join(paseoHome, "daemon.log"))).toBe(false);
|
||||
expect(existsSync(missingLogDir)).toBe(false);
|
||||
const result = resolveLogConfig(config, { paseoHome });
|
||||
expect(result).toEqual({
|
||||
level: "warn",
|
||||
console: {
|
||||
level: "warn",
|
||||
format: "json",
|
||||
},
|
||||
file: {
|
||||
level: "warn",
|
||||
path: path.join(paseoHome, "daemon.log"),
|
||||
rotate: {
|
||||
maxSize: "10m",
|
||||
maxFiles: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("writes to an explicit file target without creating rotation files", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-file-"));
|
||||
const logPath = path.join(paseoHome, "logs", "programmatic.log");
|
||||
it("keeps backwards compatibility for legacy env vars", () => {
|
||||
process.env.PASEO_LOG = "error";
|
||||
process.env.PASEO_LOG_FORMAT = "json";
|
||||
|
||||
await runLoggerFixture(`
|
||||
const logger = createRootLogger(
|
||||
{ log: { file: { path: ${JSON.stringify(logPath)} } } },
|
||||
{ paseoHome: ${JSON.stringify(paseoHome)} },
|
||||
);
|
||||
logger.info({ proof: "file-explicit" }, "explicit file logger");
|
||||
logger.flush();
|
||||
`);
|
||||
const result = resolveLogConfig(undefined, { paseoHome });
|
||||
expect(result).toEqual({
|
||||
level: "error",
|
||||
console: {
|
||||
level: "error",
|
||||
format: "json",
|
||||
},
|
||||
file: {
|
||||
level: "error",
|
||||
path: path.join(paseoHome, "daemon.log"),
|
||||
rotate: {
|
||||
maxSize: "10m",
|
||||
maxFiles: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const logText = await readFile(logPath, "utf8");
|
||||
const files = await readdir(path.dirname(logPath));
|
||||
it("supports partial destination config and retains defaults", () => {
|
||||
const config: PersistedConfig = {
|
||||
log: {
|
||||
console: {
|
||||
level: "warn",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(logText).toContain('"proof":"file-explicit"');
|
||||
expect(logText).toContain('"msg":"explicit file logger"');
|
||||
expect(files).toEqual(["programmatic.log"]);
|
||||
const result = resolveLogConfig(config, { paseoHome });
|
||||
expect(result).toEqual({
|
||||
level: "debug",
|
||||
console: {
|
||||
level: "warn",
|
||||
format: "pretty",
|
||||
},
|
||||
file: {
|
||||
level: "debug",
|
||||
path: path.join(paseoHome, "daemon.log"),
|
||||
rotate: {
|
||||
maxSize: "10m",
|
||||
maxFiles: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("can disable file output for supervised workers", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-no-worker-file-"));
|
||||
const logPath = path.join(paseoHome, "daemon.log");
|
||||
|
||||
const { stdout } = await runLoggerFixture(`
|
||||
const logger = createRootLogger(
|
||||
{ log: { file: { path: ${JSON.stringify(logPath)} } } },
|
||||
{ paseoHome: ${JSON.stringify(paseoHome)}, file: false },
|
||||
);
|
||||
logger.info({ proof: "stdout-only" }, "worker logger");
|
||||
logger.flush();
|
||||
`);
|
||||
it("ignores invalid rotate count env var and falls back to config value", () => {
|
||||
process.env.PASEO_LOG_FILE_ROTATE_COUNT = "0";
|
||||
const config: PersistedConfig = {
|
||||
log: {
|
||||
file: {
|
||||
rotate: {
|
||||
maxFiles: 7,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(stdout).toContain('"proof":"stdout-only"');
|
||||
expect(stdout).toContain('"msg":"worker logger"');
|
||||
expect(existsSync(logPath)).toBe(false);
|
||||
const result = resolveLogConfig(config, { paseoHome });
|
||||
expect(result.file.rotate.maxFiles).toBe(7);
|
||||
});
|
||||
|
||||
it("keeps pretty output available as a format choice", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-pretty-"));
|
||||
|
||||
const { stdout } = await runLoggerFixture(`
|
||||
const logger = createRootLogger({ level: "info", format: "pretty" }, {
|
||||
paseoHome: ${JSON.stringify(paseoHome)},
|
||||
});
|
||||
logger.info("pretty logger");
|
||||
logger.flush();
|
||||
`);
|
||||
it("supports all log levels for destination-specific env vars", () => {
|
||||
const levels: Array<"trace" | "debug" | "info" | "warn" | "error" | "fatal"> = [
|
||||
"trace",
|
||||
"debug",
|
||||
"info",
|
||||
"warn",
|
||||
"error",
|
||||
"fatal",
|
||||
];
|
||||
|
||||
expect(stdout).toContain("pretty logger");
|
||||
for (const level of levels) {
|
||||
process.env.PASEO_LOG_CONSOLE_LEVEL = level;
|
||||
process.env.PASEO_LOG_FILE_LEVEL = level;
|
||||
const result = resolveLogConfig(undefined, { paseoHome });
|
||||
expect(result.console.level).toBe(level);
|
||||
expect(result.file.level).toBe(level);
|
||||
expect(result.level).toBe(level);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import pretty from "pino-pretty";
|
||||
import { createStream as createRotatingFileStream } from "rotating-file-stream";
|
||||
import type { PersistedConfig } from "./persisted-config.js";
|
||||
import { resolvePaseoHome } from "./paseo-home.js";
|
||||
|
||||
@@ -14,9 +15,13 @@ export interface ResolvedLogConfig {
|
||||
level: LogLevel;
|
||||
format: LogFormat;
|
||||
};
|
||||
file?: {
|
||||
file: {
|
||||
level: LogLevel;
|
||||
path: string;
|
||||
rotate: {
|
||||
maxSize: string;
|
||||
maxFiles: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,9 +34,11 @@ type LoggerConfigInput = PersistedConfig | LegacyLogConfig | undefined;
|
||||
|
||||
interface ResolveLogConfigOptions {
|
||||
paseoHome?: string;
|
||||
file?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Set<LogLevel> = new Set(["trace", "debug", "info", "warn", "error", "fatal"]);
|
||||
const LOG_FORMATS: Set<LogFormat> = new Set(["pretty", "json"]);
|
||||
const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
|
||||
trace: 10,
|
||||
debug: 20,
|
||||
@@ -42,8 +49,10 @@ const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
|
||||
};
|
||||
|
||||
const DEFAULT_CONSOLE_LEVEL: LogLevel = "info";
|
||||
const DEFAULT_CONSOLE_FORMAT: LogFormat = "json";
|
||||
const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty";
|
||||
const DEFAULT_FILE_LEVEL: LogLevel = "debug";
|
||||
const DEFAULT_FILE_ROTATE_SIZE = "10m";
|
||||
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
|
||||
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const REDACT_PATHS = [
|
||||
"authorization",
|
||||
@@ -60,6 +69,33 @@ const REDACT_PATHS = [
|
||||
"req.headers.Sec-WebSocket-Protocol",
|
||||
];
|
||||
|
||||
function parseLogLevel(value: string | undefined): LogLevel | undefined {
|
||||
if (!value || !LOG_LEVELS.has(value as LogLevel)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as LogLevel;
|
||||
}
|
||||
|
||||
function parseLogFormat(value: string | undefined): LogFormat | undefined {
|
||||
if (!value || !LOG_FORMATS.has(value as LogFormat)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as LogFormat;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined): number | undefined {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resolveFilePath(paseoHome: string, configuredPath: string | undefined): string {
|
||||
const fallback = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
|
||||
if (!configuredPath) {
|
||||
@@ -89,7 +125,7 @@ function resolveConfiguredPaseoHome(options: ResolveLogConfigOptions | undefined
|
||||
if (options?.paseoHome) {
|
||||
return options.paseoHome;
|
||||
}
|
||||
return resolvePaseoHome();
|
||||
return resolvePaseoHome(options?.env ?? process.env);
|
||||
}
|
||||
|
||||
function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig | undefined {
|
||||
@@ -114,50 +150,126 @@ function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig
|
||||
return config as PersistedConfig;
|
||||
}
|
||||
|
||||
function rotateOnRestart(filePath: string, maxFiles: number): void {
|
||||
if (!existsSync(filePath)) return;
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
const base = path.basename(filePath);
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
|
||||
|
||||
try {
|
||||
renameSync(filePath, path.join(dir, `${ts}-00-${base}`));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up old rotated logs beyond maxFiles.
|
||||
// Both our restart-rotated files (YYYYMMDD-HHMM-00-daemon.log) and
|
||||
// rotating-file-stream's size-rotated files (YYYYMMDD-HHMM-NN-daemon.log)
|
||||
// end with -${base} and sort chronologically by name.
|
||||
const rotatedFiles = readdirSync(dir)
|
||||
.filter((f) => f.endsWith(`-${base}`) && f !== base)
|
||||
.sort()
|
||||
.toReversed();
|
||||
|
||||
for (const file of rotatedFiles.slice(maxFiles)) {
|
||||
try {
|
||||
unlinkSync(path.join(dir, file));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function toRotatingFileStreamSize(size: string): string {
|
||||
const trimmed = size.trim();
|
||||
const match = trimmed.match(/^(\d+)\s*([bBkKmMgG])?$/);
|
||||
if (!match) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const value = match[1];
|
||||
const unit = (match[2] ?? "M").toUpperCase();
|
||||
return `${value}${unit}`;
|
||||
}
|
||||
|
||||
interface LogLevelResolution {
|
||||
consoleLevel: LogLevel;
|
||||
fileLevel?: LogLevel;
|
||||
fileLevel: LogLevel;
|
||||
consoleFormat: LogFormat;
|
||||
}
|
||||
|
||||
function resolveLogLevelsAndFormat(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persistedLog: NonNullable<ReturnType<typeof normalizeLoggerConfigInput>>["log"] | undefined,
|
||||
): LogLevelResolution {
|
||||
const envGlobalLevel = parseLogLevel(env.PASEO_LOG);
|
||||
const persistedGlobalLevel = persistedLog?.level;
|
||||
const consoleLevel: LogLevel =
|
||||
persistedLog?.console?.level ?? persistedGlobalLevel ?? DEFAULT_CONSOLE_LEVEL;
|
||||
const fileLevel = persistedLog?.file
|
||||
? (persistedLog.file.level ?? persistedGlobalLevel ?? DEFAULT_FILE_LEVEL)
|
||||
: undefined;
|
||||
parseLogLevel(env.PASEO_LOG_CONSOLE_LEVEL) ??
|
||||
envGlobalLevel ??
|
||||
persistedLog?.console?.level ??
|
||||
persistedGlobalLevel ??
|
||||
DEFAULT_CONSOLE_LEVEL;
|
||||
const fileLevel: LogLevel =
|
||||
parseLogLevel(env.PASEO_LOG_FILE_LEVEL) ??
|
||||
envGlobalLevel ??
|
||||
persistedLog?.file?.level ??
|
||||
persistedGlobalLevel ??
|
||||
DEFAULT_FILE_LEVEL;
|
||||
const consoleFormat: LogFormat =
|
||||
persistedLog?.console?.format ?? persistedLog?.format ?? DEFAULT_CONSOLE_FORMAT;
|
||||
parseLogFormat(env.PASEO_LOG_FORMAT) ??
|
||||
persistedLog?.console?.format ??
|
||||
persistedLog?.format ??
|
||||
DEFAULT_CONSOLE_FORMAT;
|
||||
return { consoleLevel, fileLevel, consoleFormat };
|
||||
}
|
||||
|
||||
interface RotateResolution {
|
||||
maxSize: string;
|
||||
maxFiles: number;
|
||||
}
|
||||
|
||||
function resolveRotateConfig(
|
||||
env: NodeJS.ProcessEnv,
|
||||
persistedLog: NonNullable<ReturnType<typeof normalizeLoggerConfigInput>>["log"] | undefined,
|
||||
): RotateResolution {
|
||||
return {
|
||||
maxSize:
|
||||
env.PASEO_LOG_FILE_ROTATE_SIZE?.trim() ||
|
||||
persistedLog?.file?.rotate?.maxSize ||
|
||||
DEFAULT_FILE_ROTATE_SIZE,
|
||||
maxFiles:
|
||||
parsePositiveInteger(env.PASEO_LOG_FILE_ROTATE_COUNT) ??
|
||||
persistedLog?.file?.rotate?.maxFiles ??
|
||||
DEFAULT_FILE_ROTATE_MAX_FILES,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLogConfig(
|
||||
configInput: LoggerConfigInput,
|
||||
options?: ResolveLogConfigOptions,
|
||||
): ResolvedLogConfig {
|
||||
const persistedConfig = normalizeLoggerConfigInput(configInput);
|
||||
const env = options?.env ?? process.env;
|
||||
const paseoHome = resolveConfiguredPaseoHome(options);
|
||||
const persistedLog = persistedConfig?.log;
|
||||
|
||||
const { consoleLevel, fileLevel, consoleFormat } = resolveLogLevelsAndFormat(persistedLog);
|
||||
const file =
|
||||
options?.file !== false && persistedLog?.file
|
||||
? {
|
||||
level: fileLevel ?? DEFAULT_FILE_LEVEL,
|
||||
path: resolveFilePath(paseoHome, persistedLog.file.path),
|
||||
}
|
||||
: undefined;
|
||||
const { consoleLevel, fileLevel, consoleFormat } = resolveLogLevelsAndFormat(env, persistedLog);
|
||||
const filePath = resolveFilePath(paseoHome, env.PASEO_LOG_FILE_PATH ?? persistedLog?.file?.path);
|
||||
const rotate = resolveRotateConfig(env, persistedLog);
|
||||
|
||||
return {
|
||||
level: minLogLevel(file ? [consoleLevel, file.level] : [consoleLevel]),
|
||||
level: minLogLevel([consoleLevel, fileLevel]),
|
||||
console: {
|
||||
level: consoleLevel,
|
||||
format: consoleFormat,
|
||||
},
|
||||
...(file ? { file } : {}),
|
||||
file: {
|
||||
level: fileLevel,
|
||||
path: filePath,
|
||||
rotate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,26 +278,32 @@ export function createRootLogger(
|
||||
options?: ResolveLogConfigOptions,
|
||||
): pino.Logger {
|
||||
const config = resolveLogConfig(configInput, options);
|
||||
if (config.file) {
|
||||
mkdirSync(path.dirname(config.file.path), { recursive: true });
|
||||
}
|
||||
|
||||
const stream =
|
||||
mkdirSync(path.dirname(config.file.path), { recursive: true });
|
||||
|
||||
const consoleStream =
|
||||
config.console.format === "pretty"
|
||||
? pretty({
|
||||
colorize: true,
|
||||
singleLine: true,
|
||||
ignore: "pid,hostname",
|
||||
destination: config.file?.path ?? 1,
|
||||
})
|
||||
: pino.destination({ dest: config.file?.path ?? 1, sync: false });
|
||||
: pino.destination({ dest: 1, sync: false });
|
||||
|
||||
rotateOnRestart(config.file.path, config.file.rotate.maxFiles);
|
||||
|
||||
const fileStream = createRotatingFileStream(path.basename(config.file.path), {
|
||||
path: path.dirname(config.file.path),
|
||||
size: toRotatingFileStreamSize(config.file.rotate.maxSize),
|
||||
maxFiles: config.file.rotate.maxFiles,
|
||||
});
|
||||
|
||||
return pino(
|
||||
{
|
||||
level: config.file?.level ?? config.console.level,
|
||||
redact: { paths: REDACT_PATHS, remove: true },
|
||||
},
|
||||
stream,
|
||||
{ level: config.level, redact: { paths: REDACT_PATHS, remove: true } },
|
||||
pino.multistream([
|
||||
{ level: config.console.level, stream: consoleStream },
|
||||
{ level: config.file.level, stream: fileStream },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
function shouldPrintPairingQr(): boolean {
|
||||
const env = parseBooleanEnv(process.env.PASEO_PAIRING_QR);
|
||||
if (env !== undefined) return env;
|
||||
return process.stdout.isTTY ?? false;
|
||||
return Boolean(process.stdout.isTTY);
|
||||
}
|
||||
|
||||
export async function renderPairingQr(url: string): Promise<string> {
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
|
||||
export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
|
||||
export const LogFormatSchema = z.enum(["pretty", "json"]);
|
||||
const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
|
||||
const LogFormatSchema = z.enum(["pretty", "json"]);
|
||||
|
||||
const LogConfigSchema = z
|
||||
.object({
|
||||
|
||||
@@ -32,21 +32,6 @@ import type {
|
||||
TurnDetectionProvider,
|
||||
TurnDetectionSession,
|
||||
} from "./speech/turn-detection-provider.js";
|
||||
import {
|
||||
asSessionInternals as asSessionInternalsHelper,
|
||||
asAgentManager,
|
||||
asAgentStorage,
|
||||
asDownloadTokenStore,
|
||||
asPushTokenStore,
|
||||
asChatService,
|
||||
asScheduleService,
|
||||
asLoopService,
|
||||
asCheckoutDiffManager,
|
||||
asGitHubService,
|
||||
asWorkspaceGitService,
|
||||
asDaemonConfigStore,
|
||||
createProviderSnapshotManagerStub,
|
||||
} from "./test-utils/session-stubs.js";
|
||||
|
||||
interface SessionHandlerInternals {
|
||||
startVoiceTurnController(): Promise<void>;
|
||||
@@ -79,14 +64,10 @@ interface SessionHandlerInternals {
|
||||
handleStashPopRequest(params: unknown): Promise<unknown>;
|
||||
createPaseoWorktree(params: unknown): Promise<unknown>;
|
||||
handleStartWorkspaceScriptRequest(params: unknown): Promise<unknown>;
|
||||
getProviderRegistry(): unknown;
|
||||
sttManager: {
|
||||
transcribe(audio: Buffer, format: string): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function asSessionInternals(session: Session): SessionHandlerInternals {
|
||||
return asSessionInternalsHelper<SessionHandlerInternals>(session);
|
||||
return session as unknown as SessionHandlerInternals;
|
||||
}
|
||||
|
||||
function createBinaryMessageHandler(
|
||||
@@ -269,9 +250,9 @@ interface SessionForTestOptions {
|
||||
};
|
||||
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
|
||||
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
|
||||
terminalManager?: SessionOptions["terminalManager"];
|
||||
scriptRouteStore?: SessionOptions["scriptRouteStore"];
|
||||
scriptRuntimeStore?: SessionOptions["scriptRuntimeStore"];
|
||||
terminalManager?: unknown;
|
||||
scriptRouteStore?: unknown;
|
||||
scriptRuntimeStore?: unknown;
|
||||
getDaemonTcpPort?: () => number | null;
|
||||
getDaemonTcpHost?: () => string | null;
|
||||
providerSnapshotManager?: ProviderSnapshotManager;
|
||||
@@ -309,17 +290,17 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
onMessage: (message) => messages.push(message),
|
||||
onBinaryMessage: createBinaryMessageHandler(options.binaryMessages),
|
||||
logger,
|
||||
downloadTokenStore: asDownloadTokenStore(),
|
||||
pushTokenStore: asPushTokenStore(),
|
||||
downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"],
|
||||
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"],
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
agentManager: asAgentManager({
|
||||
agentManager: {
|
||||
listAgents: vi.fn(() => []),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
}),
|
||||
agentStorage: asAgentStorage({
|
||||
} as unknown as SessionOptions["agentManager"],
|
||||
agentStorage: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
projectRegistry: options.projectRegistry ?? {
|
||||
} as unknown as SessionOptions["agentStorage"],
|
||||
projectRegistry: (options.projectRegistry ?? {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
@@ -327,30 +308,30 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
remove: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
existsOnDisk: vi.fn(),
|
||||
},
|
||||
workspaceRegistry: options.workspaceRegistry ?? {
|
||||
}) as unknown as SessionOptions["projectRegistry"],
|
||||
workspaceRegistry: (options.workspaceRegistry ?? {
|
||||
get: vi.fn(),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
chatService: asChatService(),
|
||||
scheduleService: asScheduleService(),
|
||||
loopService: asLoopService(),
|
||||
checkoutDiffManager: asCheckoutDiffManager(checkoutDiffManager),
|
||||
github: asGitHubService(github),
|
||||
workspaceGitService: asWorkspaceGitService(workspaceGitService),
|
||||
daemonConfigStore: asDaemonConfigStore({
|
||||
}) as unknown as SessionOptions["workspaceRegistry"],
|
||||
chatService: {} as unknown as SessionOptions["chatService"],
|
||||
scheduleService: {} as unknown as SessionOptions["scheduleService"],
|
||||
loopService: {} as unknown as SessionOptions["loopService"],
|
||||
checkoutDiffManager: checkoutDiffManager as unknown as SessionOptions["checkoutDiffManager"],
|
||||
github: github as unknown as SessionOptions["github"],
|
||||
workspaceGitService: workspaceGitService as unknown as SessionOptions["workspaceGitService"],
|
||||
daemonConfigStore: {
|
||||
get: vi.fn(() => ({
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
})),
|
||||
onChange: vi.fn(() => () => {}),
|
||||
}),
|
||||
} as unknown as SessionOptions["daemonConfigStore"],
|
||||
stt: options.stt ?? null,
|
||||
tts: null,
|
||||
terminalManager: options.terminalManager ?? null,
|
||||
terminalManager: (options.terminalManager ?? null) as SessionOptions["terminalManager"],
|
||||
providerSnapshotManager: options.providerSnapshotManager,
|
||||
scriptRouteStore: options.scriptRouteStore,
|
||||
scriptRuntimeStore: options.scriptRuntimeStore,
|
||||
scriptRouteStore: options.scriptRouteStore as SessionOptions["scriptRouteStore"],
|
||||
scriptRuntimeStore: options.scriptRuntimeStore as SessionOptions["scriptRuntimeStore"],
|
||||
getDaemonTcpPort: options.getDaemonTcpPort,
|
||||
getDaemonTcpHost: options.getDaemonTcpHost,
|
||||
voice: options.voice,
|
||||
@@ -418,7 +399,16 @@ function createVoiceSessionHarness() {
|
||||
const sendAgentMessage = vi
|
||||
.spyOn(internals, "handleSendAgentMessage")
|
||||
.mockResolvedValue({ ok: true });
|
||||
const transcribe = vi.spyOn(asSessionInternals(session).sttManager, "transcribe");
|
||||
const transcribe = vi.spyOn(
|
||||
(
|
||||
session as unknown as {
|
||||
sttManager: {
|
||||
transcribe(audio: Buffer, format: string): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).sttManager,
|
||||
"transcribe",
|
||||
);
|
||||
|
||||
return {
|
||||
session,
|
||||
@@ -916,6 +906,20 @@ function createWorkspaceGitSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderSnapshotManagerStub(): ProviderSnapshotManager {
|
||||
const stub = {
|
||||
getSnapshot: vi.fn(() => []),
|
||||
refreshSnapshotForCwd: vi.fn(async () => {}),
|
||||
refreshSettingsSnapshot: vi.fn(async () => {}),
|
||||
warmUpSnapshotForCwd: vi.fn(async () => {}),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
};
|
||||
stub.on.mockImplementation(() => stub);
|
||||
stub.off.mockImplementation(() => stub);
|
||||
return stub as unknown as ProviderSnapshotManager;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -1035,11 +1039,7 @@ describe("session PR status payload normalization", () => {
|
||||
|
||||
describe("session provider refresh cwd routing", () => {
|
||||
test("routes no-cwd provider snapshot refreshes through settings refresh", async () => {
|
||||
const {
|
||||
manager: providerSnapshotManager,
|
||||
refreshSettingsSnapshot,
|
||||
refreshSnapshotForCwd,
|
||||
} = createProviderSnapshotManagerStub();
|
||||
const providerSnapshotManager = createProviderSnapshotManagerStub();
|
||||
const session = createSessionForTest({ providerSnapshotManager });
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -1048,18 +1048,14 @@ describe("session provider refresh cwd routing", () => {
|
||||
requestId: "refresh-settings",
|
||||
});
|
||||
|
||||
expect(refreshSettingsSnapshot).toHaveBeenCalledWith({
|
||||
expect(providerSnapshotManager.refreshSettingsSnapshot).toHaveBeenCalledWith({
|
||||
providers: ["codex"],
|
||||
});
|
||||
expect(refreshSnapshotForCwd).not.toHaveBeenCalled();
|
||||
expect(providerSnapshotManager.refreshSnapshotForCwd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("routes cwd provider snapshot refreshes through workspace refresh", async () => {
|
||||
const {
|
||||
manager: providerSnapshotManager,
|
||||
refreshSnapshotForCwd,
|
||||
refreshSettingsSnapshot,
|
||||
} = createProviderSnapshotManagerStub();
|
||||
const providerSnapshotManager = createProviderSnapshotManagerStub();
|
||||
const session = createSessionForTest({ providerSnapshotManager });
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -1069,11 +1065,11 @@ describe("session provider refresh cwd routing", () => {
|
||||
requestId: "refresh-workspace",
|
||||
});
|
||||
|
||||
expect(refreshSnapshotForCwd).toHaveBeenCalledWith({
|
||||
expect(providerSnapshotManager.refreshSnapshotForCwd).toHaveBeenCalledWith({
|
||||
cwd: "/tmp/workspace-refresh",
|
||||
providers: ["codex"],
|
||||
});
|
||||
expect(refreshSettingsSnapshot).not.toHaveBeenCalled();
|
||||
expect(providerSnapshotManager.refreshSettingsSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("normalizes legacy model and mode list requests without cwd to home", async () => {
|
||||
@@ -1081,7 +1077,7 @@ describe("session provider refresh cwd routing", () => {
|
||||
const session = createSessionForTest({ messages });
|
||||
const fetchModels = vi.fn(async () => []);
|
||||
const fetchModes = vi.fn(async () => []);
|
||||
asSessionInternals(session).getProviderRegistry = () => ({
|
||||
(session as unknown as { getProviderRegistry: () => unknown }).getProviderRegistry = () => ({
|
||||
codex: createTestProviderDefinition({
|
||||
fetchModels,
|
||||
fetchModes,
|
||||
@@ -1105,8 +1101,7 @@ describe("session provider refresh cwd routing", () => {
|
||||
|
||||
test("legacy model list request treats disabled snapshot entries as unavailable without warming", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const { manager: providerSnapshotManager, warmUpSnapshotForCwd } =
|
||||
createProviderSnapshotManagerStub();
|
||||
const providerSnapshotManager = createProviderSnapshotManagerStub();
|
||||
providerSnapshotManager.getSnapshot = vi.fn(() => [
|
||||
{
|
||||
provider: "codex",
|
||||
@@ -1122,7 +1117,7 @@ describe("session provider refresh cwd routing", () => {
|
||||
requestId: "models-disabled",
|
||||
});
|
||||
|
||||
expect(warmUpSnapshotForCwd).not.toHaveBeenCalled();
|
||||
expect(providerSnapshotManager.warmUpSnapshotForCwd).not.toHaveBeenCalled();
|
||||
expect(messages).toContainEqual({
|
||||
type: "list_provider_models_response",
|
||||
payload: {
|
||||
@@ -1136,8 +1131,7 @@ describe("session provider refresh cwd routing", () => {
|
||||
|
||||
test("legacy mode list request treats disabled snapshot entries as unavailable without warming", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const { manager: providerSnapshotManager, warmUpSnapshotForCwd } =
|
||||
createProviderSnapshotManagerStub();
|
||||
const providerSnapshotManager = createProviderSnapshotManagerStub();
|
||||
providerSnapshotManager.getSnapshot = vi.fn(() => [
|
||||
{
|
||||
provider: "codex",
|
||||
@@ -1153,7 +1147,7 @@ describe("session provider refresh cwd routing", () => {
|
||||
requestId: "modes-disabled",
|
||||
});
|
||||
|
||||
expect(warmUpSnapshotForCwd).not.toHaveBeenCalled();
|
||||
expect(providerSnapshotManager.warmUpSnapshotForCwd).not.toHaveBeenCalled();
|
||||
expect(messages).toContainEqual({
|
||||
type: "list_provider_modes_response",
|
||||
payload: {
|
||||
@@ -1181,7 +1175,7 @@ describe("session provider refresh cwd routing", () => {
|
||||
label: "Should not fetch",
|
||||
},
|
||||
]);
|
||||
asSessionInternals(session).getProviderRegistry = () => ({
|
||||
(session as unknown as { getProviderRegistry: () => unknown }).getProviderRegistry = () => ({
|
||||
codex: createTestProviderDefinition({
|
||||
enabled: false,
|
||||
fetchModels,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type pino from "pino";
|
||||
import { Session, type SessionOptions } from "./session.js";
|
||||
import { asInternals, createStub } from "./test-utils/class-mocks.js";
|
||||
import type {
|
||||
WorkspaceGitListener,
|
||||
WorkspaceGitRuntimeSnapshot,
|
||||
@@ -151,20 +150,20 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
onMessage: (message) => emitted.push(message as { type: string; payload: unknown }),
|
||||
logger: createStub<pino.Logger>(logger),
|
||||
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
|
||||
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
|
||||
logger: logger as unknown as pino.Logger,
|
||||
downloadTokenStore: {} as SessionOptions["downloadTokenStore"],
|
||||
pushTokenStore: {} as SessionOptions["pushTokenStore"],
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: createStub<SessionOptions["agentManager"]>({
|
||||
agentManager: {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
}),
|
||||
agentStorage: createStub<SessionOptions["agentStorage"]>({
|
||||
} as unknown as SessionOptions["agentManager"],
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
}),
|
||||
projectRegistry: createStub<SessionOptions["projectRegistry"]>({
|
||||
} as unknown as SessionOptions["agentStorage"],
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
@@ -180,8 +179,8 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
remove: async (projectId: string) => {
|
||||
projects.delete(projectId);
|
||||
},
|
||||
}),
|
||||
workspaceRegistry: createStub<SessionOptions["workspaceRegistry"]>({
|
||||
} as unknown as SessionOptions["projectRegistry"],
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
@@ -197,8 +196,8 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
remove: async (workspaceId: string) => {
|
||||
workspaces.delete(workspaceId);
|
||||
},
|
||||
}),
|
||||
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
|
||||
} as unknown as SessionOptions["workspaceRegistry"],
|
||||
checkoutDiffManager: {
|
||||
subscribe: async () => ({
|
||||
initial: { cwd: "/tmp", files: [], error: null },
|
||||
unsubscribe: () => {},
|
||||
@@ -211,15 +210,15 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
}),
|
||||
dispose: () => {},
|
||||
}),
|
||||
} as unknown as SessionOptions["checkoutDiffManager"],
|
||||
workspaceGitService,
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
});
|
||||
} as unknown as SessionOptions);
|
||||
|
||||
asInternals<SessionInternals>(session).listAgentPayloads = async () => [];
|
||||
(session as unknown as SessionInternals).listAgentPayloads = async () => [];
|
||||
|
||||
return {
|
||||
session,
|
||||
@@ -268,7 +267,7 @@ describe("workspace git watch targets", () => {
|
||||
test("emits one workspace_update when the workspace git service emits a changed snapshot", async () => {
|
||||
const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } =
|
||||
createSessionForWorkspaceGitWatchTests();
|
||||
const sessionAny = asInternals<SessionInternals>(session);
|
||||
const sessionAny = session as unknown as SessionInternals;
|
||||
seedGitWorkspace({
|
||||
projects,
|
||||
workspaces,
|
||||
@@ -343,7 +342,7 @@ describe("workspace git watch targets", () => {
|
||||
test("emits checkout_status_update to a client subscribed to the workspace git target", async () => {
|
||||
const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } =
|
||||
createSessionForWorkspaceGitWatchTests();
|
||||
const sessionAny = asInternals<SessionInternals>(session);
|
||||
const sessionAny = session as unknown as SessionInternals;
|
||||
seedGitWorkspace({
|
||||
projects,
|
||||
workspaces,
|
||||
@@ -406,7 +405,7 @@ describe("workspace git watch targets", () => {
|
||||
test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => {
|
||||
const { session, emitted, projects, workspaces, subscriptions } =
|
||||
createSessionForWorkspaceGitWatchTests();
|
||||
const sessionAny = asInternals<SessionInternals>(session);
|
||||
const sessionAny = session as unknown as SessionInternals;
|
||||
seedGitWorkspace({
|
||||
projects,
|
||||
workspaces,
|
||||
|
||||
@@ -11,7 +11,6 @@ import { expect, test, vi } from "vitest";
|
||||
import { Session, type SessionOptions } from "./session.js";
|
||||
import type { SessionOutboundMessage } from "../shared/messages.js";
|
||||
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
|
||||
import { asInternals, createStub } from "./test-utils/class-mocks.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
@@ -88,11 +87,11 @@ function createHarness(input: {
|
||||
clientId: "test",
|
||||
appVersion: null,
|
||||
onMessage: (m) => emitted.push(m),
|
||||
logger: createStub<SessionOptions["logger"]>(logger),
|
||||
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
|
||||
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
|
||||
logger: logger as unknown as SessionOptions["logger"],
|
||||
downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"],
|
||||
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"],
|
||||
paseoHome: mkdtempSync(path.join(tmpdir(), "paseo-invariant-test-")),
|
||||
agentManager: createStub<SessionOptions["agentManager"]>({
|
||||
agentManager: {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
@@ -100,12 +99,12 @@ function createHarness(input: {
|
||||
archiveSnapshot: async () => ({}),
|
||||
clearAgentAttention: async () => {},
|
||||
notifyAgentState: () => {},
|
||||
}),
|
||||
agentStorage: createStub<SessionOptions["agentStorage"]>({
|
||||
} as unknown as SessionOptions["agentManager"],
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
}),
|
||||
projectRegistry: createStub<SessionOptions["projectRegistry"]>({
|
||||
} as unknown as SessionOptions["agentStorage"],
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
@@ -120,8 +119,8 @@ function createHarness(input: {
|
||||
remove: async (id: string) => {
|
||||
projects.delete(id);
|
||||
},
|
||||
}),
|
||||
workspaceRegistry: createStub<SessionOptions["workspaceRegistry"]>({
|
||||
} as unknown as SessionOptions["projectRegistry"],
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
@@ -136,11 +135,11 @@ function createHarness(input: {
|
||||
remove: async (id: string) => {
|
||||
workspaces.delete(id);
|
||||
},
|
||||
}),
|
||||
chatService: createStub<SessionOptions["chatService"]>({}),
|
||||
scheduleService: createStub<SessionOptions["scheduleService"]>({}),
|
||||
loopService: createStub<SessionOptions["loopService"]>({}),
|
||||
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
|
||||
} as unknown as SessionOptions["workspaceRegistry"],
|
||||
chatService: {} as unknown as SessionOptions["chatService"],
|
||||
scheduleService: {} as unknown as SessionOptions["scheduleService"],
|
||||
loopService: {} as unknown as SessionOptions["loopService"],
|
||||
checkoutDiffManager: {
|
||||
subscribe: async () => ({
|
||||
initial: { cwd: "/tmp", files: [], error: null },
|
||||
unsubscribe: () => {},
|
||||
@@ -153,12 +152,12 @@ function createHarness(input: {
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
}),
|
||||
dispose: () => {},
|
||||
}),
|
||||
} as unknown as SessionOptions["checkoutDiffManager"],
|
||||
workspaceGitService,
|
||||
daemonConfigStore: createStub<SessionOptions["daemonConfigStore"]>({
|
||||
daemonConfigStore: {
|
||||
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
||||
onChange: () => () => {},
|
||||
}),
|
||||
} as unknown as SessionOptions["daemonConfigStore"],
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
@@ -169,7 +168,7 @@ function createHarness(input: {
|
||||
}
|
||||
|
||||
async function openProject(session: Session, cwd: string, requestId = "req-1") {
|
||||
await asInternals<{ handleMessage(m: unknown): Promise<unknown> }>(session).handleMessage({
|
||||
await (session as unknown as { handleMessage(m: unknown): Promise<unknown> }).handleMessage({
|
||||
type: "open_project_request",
|
||||
cwd,
|
||||
requestId,
|
||||
@@ -437,9 +436,11 @@ test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix
|
||||
workspaces: [dirWorkspace("/parent", archivedAt)],
|
||||
projects: [dirProject("/parent", archivedAt)],
|
||||
});
|
||||
const found = await asInternals<{
|
||||
findWorkspaceByDirectory(cwd: string): Promise<unknown>;
|
||||
}>(h.session).findWorkspaceByDirectory("/parent/child");
|
||||
const found = await (
|
||||
h.session as unknown as {
|
||||
findWorkspaceByDirectory(cwd: string): Promise<unknown>;
|
||||
}
|
||||
).findWorkspaceByDirectory("/parent/child");
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { Session } from "./session.js";
|
||||
import type { SessionOptions } from "./session.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { asInternals, createStub } from "./test-utils/class-mocks.js";
|
||||
|
||||
interface SessionInternals {
|
||||
archiveAgentForClose(agentId: string): Promise<{ archivedAt: string }>;
|
||||
@@ -91,53 +90,51 @@ describe("snapshot mutation ownership boundary", () => {
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const session = asInternals<SessionInternals>(
|
||||
new Session({
|
||||
clientId: "test-client",
|
||||
onMessage,
|
||||
logger: createStub<SessionOptions["logger"]>(logger),
|
||||
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
|
||||
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: createStub<SessionOptions["agentManager"]>({
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
archiveSnapshot,
|
||||
updateAgentMetadata,
|
||||
}),
|
||||
agentStorage: createStub<SessionOptions["agentStorage"]>({
|
||||
list: async () => [],
|
||||
get: async () => storedRecord,
|
||||
applySnapshot: directStorageWrite,
|
||||
upsert: directStorageWrite,
|
||||
}),
|
||||
projectRegistry: createStub<SessionOptions["projectRegistry"]>({
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
}),
|
||||
workspaceRegistry: createStub<SessionOptions["workspaceRegistry"]>({
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
}),
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
}),
|
||||
);
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
onMessage,
|
||||
logger: logger as unknown as SessionOptions["logger"],
|
||||
downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"],
|
||||
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"],
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
archiveSnapshot,
|
||||
updateAgentMetadata,
|
||||
} as unknown as SessionOptions["agentManager"],
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
get: async () => storedRecord,
|
||||
applySnapshot: directStorageWrite,
|
||||
upsert: directStorageWrite,
|
||||
} as unknown as SessionOptions["agentStorage"],
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as unknown as SessionOptions["projectRegistry"],
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as unknown as SessionOptions["workspaceRegistry"],
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
}) as unknown as SessionInternals;
|
||||
|
||||
const archiveResult = await session.archiveAgentForClose("agent-1");
|
||||
expect(archiveSnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user