mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e1eb513b6 |
@@ -1,23 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.10.1 - 2026-09-29
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer ([#5583](https://github.com/getpaseo/paseo/pull/5583) by [@yangqi](https://github.com/yangqi))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level ([#5587](https://github.com/getpaseo/paseo/pull/5587))
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools ([#5345](https://github.com/getpaseo/paseo/pull/5345) by [@zbaibg](https://github.com/zbaibg))
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads ([#5577](https://github.com/getpaseo/paseo/pull/5577))
|
||||
- Fixed a subagent opened from a split pane opening in a different pane ([#5451](https://github.com/getpaseo/paseo/pull/5451))
|
||||
- Fixed archiving a custom Codex provider's agent leaving its session in Import session ([#5572](https://github.com/getpaseo/paseo/pull/5572))
|
||||
- Fixed the `/` menu of a custom Codex provider with its own `CODEX_HOME` listing the daemon's prompts instead of its own ([#5450](https://github.com/getpaseo/paseo/pull/5450))
|
||||
- Fixed a background `send_agent_prompt` returning `idle` for a prompt the agent accepted ([#5386](https://github.com/getpaseo/paseo/pull/5386))
|
||||
- Fixed Windows file-link tooltips showing the full path for files inside the workspace ([#1987](https://github.com/getpaseo/paseo/pull/1987))
|
||||
- Fixed terminal profiles that run `cursor-agent` showing the generic terminal icon ([#5379](https://github.com/getpaseo/paseo/pull/5379))
|
||||
|
||||
## 0.10.0 - 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c=
|
||||
sha256-zhJ9vf0n5wmOP3Hg20vua91TWJcOIbm6kTSXCcryOlg=
|
||||
|
||||
@@ -1282,6 +1282,49 @@ test.describe("Agent message submission", () => {
|
||||
}
|
||||
});
|
||||
|
||||
for (const behavior of ["Steer", "Interrupt"] as const) {
|
||||
test(`keeps the next queued message queued after sending one now with ${behavior}`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: `queued-send-now-${behavior.toLowerCase()}-${testInfo.workerIndex}-`,
|
||||
model: "one-minute-stream",
|
||||
prompt: "Keep this turn active while the user sends a queued message now.",
|
||||
});
|
||||
const firstPrompt = "Send the first queued message now.";
|
||||
const secondPrompt = "Keep the second queued message queued.";
|
||||
try {
|
||||
await (behavior === "Steer"
|
||||
? configureSteerInSettings(page)
|
||||
: configureInterruptInSettings(page));
|
||||
await page.goBack();
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
await queueMessage(page, firstPrompt);
|
||||
await queueMessage(page, secondPrompt);
|
||||
const sendNow = page.getByRole("button", { name: "Send queued message now" });
|
||||
await expect(sendNow).toHaveCount(2);
|
||||
|
||||
await sendNow.first().click();
|
||||
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: firstPrompt })).toHaveCount(
|
||||
1,
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: firstPrompt }),
|
||||
).toHaveAttribute("aria-busy", "false");
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
await expect(sendNow).toHaveCount(1);
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: secondPrompt }),
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
|
||||
import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar";
|
||||
|
||||
test("agent list age advances and command center says just now for a fresh agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-relative-time-",
|
||||
title: "Relative time agent",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForAgentUpsert(session.agentId, (agent) => agent.status === "idle");
|
||||
await resetSeededPageState(page);
|
||||
await page.clock.install({ time: Date.now() });
|
||||
await openSessions(page);
|
||||
|
||||
const row = page.getByTestId(`agent-row-${getServerId()}-${session.agentId}`);
|
||||
await expect(row).toContainText("just now", { timeout: 30_000 });
|
||||
|
||||
const commandCenter = await openCommandCenter(page);
|
||||
await commandCenter.getByTestId("command-center-input").fill("Relative time agent");
|
||||
const agentResult = commandCenter.getByTestId(
|
||||
`command-center-agent-${getServerId()}:${session.agentId}`,
|
||||
);
|
||||
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
|
||||
"just now",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.clock.fastForward("03:00");
|
||||
await expect(row).toContainText("3m ago");
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().hover();
|
||||
await expect(page.getByTestId(`workspace-tab-tooltip-agent_${session.agentId}`)).toContainText(
|
||||
"3m ago",
|
||||
);
|
||||
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: "Last activity", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -17,14 +17,12 @@ import {
|
||||
hoverChatOutlinePrompt,
|
||||
chatOutlineRail,
|
||||
movePointerOffChatOutline,
|
||||
observePromptIndexRequests,
|
||||
pointAtChatOutlineRowEdge,
|
||||
pressEnterOnFocusedPrompt,
|
||||
splitCurrentPanelRight,
|
||||
withStreamingMarkdownOutline,
|
||||
expectReadingStreamedMarkdown,
|
||||
} from "../support/helpers/chat-outline";
|
||||
import { createCreationScenario } from "../support/helpers/creation";
|
||||
import {
|
||||
expectTimelineAtMaximumScrollWithPromptVisible,
|
||||
expectTimelinePromptNotMounted,
|
||||
@@ -268,21 +266,3 @@ test.describe("desktop chat outline", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("indexes a new agent's prompts only once the agent exists", async ({ page }) => {
|
||||
const promptIndex = await observePromptIndexRequests(page);
|
||||
const creation = await createCreationScenario(page);
|
||||
try {
|
||||
await page.setViewportSize(WIDE_VIEWPORT);
|
||||
await creation.openAgentDraft();
|
||||
await creation.submitPrompt("Start an agent with an outline.");
|
||||
await creation.expectPromptVisible();
|
||||
|
||||
const agentIds = await creation.agentIds();
|
||||
expect(agentIds).toHaveLength(1);
|
||||
await promptIndex.waitForRequestFor(agentIds[0]!);
|
||||
expect(promptIndex.requestedAgentIds()).toEqual(agentIds);
|
||||
} finally {
|
||||
await creation.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,25 +87,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
});
|
||||
|
||||
test("a fresh commit says just now and its age advances", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "commit-relative-time-" });
|
||||
const committedAt = new Date();
|
||||
await createFeatureCommit(workspace.repoPath, committedAt.toISOString());
|
||||
await page.clock.install({ time: committedAt });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await openChangesTreePanel(page);
|
||||
await page.getByRole("button", { name: /Commits/i }).click();
|
||||
const row = page.locator('[data-testid^="commit-row-"]').filter({ hasText: COMMIT_SUBJECT });
|
||||
await expect(row).toContainText("just now", { timeout: 30_000 });
|
||||
await page.clock.fastForward("03:00");
|
||||
await expect(row).toContainText("3m ago");
|
||||
});
|
||||
|
||||
async function createFeatureCommit(
|
||||
repoPath: string,
|
||||
authorDate = "2020-01-15T12:00:00Z",
|
||||
): Promise<void> {
|
||||
async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
|
||||
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
|
||||
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
|
||||
@@ -114,8 +96,8 @@ async function createFeatureCommit(
|
||||
stdio: "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_DATE: authorDate,
|
||||
GIT_COMMITTER_DATE: authorDate,
|
||||
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
|
||||
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
|
||||
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
{
|
||||
@@ -339,16 +338,6 @@ function expectPopoverFramesStable(frames: PopoverFrame[]): void {
|
||||
).toBeUndefined();
|
||||
}
|
||||
|
||||
function recordCommandRequestsByAgentId(page: Page): string[] {
|
||||
const requests: string[] = [];
|
||||
const record = ({ payload }: { payload: string | Buffer }) => {
|
||||
if (typeof payload !== "string" || !payload.includes("list_commands_request")) return;
|
||||
if (!payload.includes("draftConfig")) requests.push(payload);
|
||||
};
|
||||
page.on("websocket", (socket) => socket.on("framesent", record));
|
||||
return requests;
|
||||
}
|
||||
|
||||
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
|
||||
const firstVisibleIndex = frames.findIndex(
|
||||
(frame) =>
|
||||
@@ -370,25 +359,6 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
||||
page,
|
||||
}) => {
|
||||
const commandRequestsByAgentId = recordCommandRequestsByAgentId(page);
|
||||
|
||||
await page.goto("/new");
|
||||
await expectNewWorkspaceProjectSelected(page, "Choose project");
|
||||
await composerLocator(page).fill("/");
|
||||
|
||||
const popover = page
|
||||
.getByTestId("composer-autocomplete-popover")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(popover).toContainText("Choose a project to see commands", { timeout: 30_000 });
|
||||
await expect(popover).not.toContainText("/clear");
|
||||
await expect(popover).not.toContainText("/exit");
|
||||
expect(commandRequestsByAgentId).toEqual([]);
|
||||
});
|
||||
|
||||
test("stays visible after returning from app-wide routes", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
|
||||
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
|
||||
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
|
||||
const ring = meter.locator("svg");
|
||||
const progressColor = await ring
|
||||
.locator("circle")
|
||||
.last()
|
||||
.evaluate((circle) => getComputedStyle(circle).stroke);
|
||||
const screenshot = await ring.screenshot();
|
||||
return ring.evaluate(
|
||||
async (_svg, { png, color }) => {
|
||||
const image = await createImageBitmap(
|
||||
await (await fetch(`data:image/png;base64,${png}`)).blob(),
|
||||
);
|
||||
const canvas = new OffscreenCanvas(image.width, image.height);
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(image, 0, 0);
|
||||
const { data } = context.getImageData(0, 0, image.width, image.height);
|
||||
const [r, g, b] = color.match(/\d+/g)!.map(Number);
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let count = 0;
|
||||
for (let y = 0; y < image.height; y += 1) {
|
||||
for (let x = 0; x < image.width; x += 1) {
|
||||
const i = (y * image.width + x) * 4;
|
||||
const distance =
|
||||
Math.abs(data[i] - r) + Math.abs(data[i + 1] - g) + Math.abs(data[i + 2] - b);
|
||||
if (distance < 40) {
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count === 0) {
|
||||
throw new Error(`No pixels painted in the progress colour ${color}`);
|
||||
}
|
||||
return { x: sumX / count - image.width / 2, y: sumY / count - image.height / 2 };
|
||||
},
|
||||
{ png: screenshot.toString("base64"), color: progressColor },
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("context window meter", () => {
|
||||
test("draws usage clockwise from twelve o'clock", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
// 32,000 of the mock's 128,000-token window: a quarter, from twelve to three o'clock.
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-meter-",
|
||||
title: "Context window meter e2e",
|
||||
initialPrompt: "emit 32000 byte file agent stream payload",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
|
||||
|
||||
const centroid = await progressArcCentroid(meter);
|
||||
expect(centroid.x).toBeGreaterThan(1);
|
||||
expect(centroid.y).toBeLessThan(-1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
expectFileTabOpen,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -96,40 +92,6 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "file-link-tooltip-" });
|
||||
const filePath = path.join(workspace.repoPath, "src", "target.ts");
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Absolute file link tooltip",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
initialPrompt: "Show the file link",
|
||||
featureValues: { mockAssistantResponse: `[Open target](${filePath}:42-45)` },
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
||||
);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await workspaceRow.click();
|
||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).click();
|
||||
|
||||
const fileLink = page.getByRole("link", { name: "Open target" }).first();
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.hover();
|
||||
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { openHostSection, selectSettingsHost } from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
startRestartableHostDaemon,
|
||||
type RestartableHostDaemon,
|
||||
} from "../support/helpers/versioned-host-daemon";
|
||||
|
||||
const PREVIOUS_VERSION = "0.8.0";
|
||||
const UPDATED_VERSION = "0.9.1";
|
||||
const HOST_LABEL = "Version restart QA";
|
||||
const NO_RELOAD_MARKER = "before-restart";
|
||||
|
||||
interface ReloadMarkerWindow {
|
||||
__paseoE2eHostVersionMarker?: string;
|
||||
}
|
||||
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
let hostDaemon: RestartableHostDaemon | null = null;
|
||||
|
||||
test.afterEach(async () => {
|
||||
await hostDaemon?.dispose();
|
||||
hostDaemon = null;
|
||||
});
|
||||
|
||||
test("host page shows the restarted daemon's version without reloading", async ({ page }) => {
|
||||
const host = await startRestartableHostDaemon(PREVIOUS_VERSION);
|
||||
hostDaemon = host;
|
||||
const otherHostVersion =
|
||||
await test.step("the connected host reports its current daemon version", async () => {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: host.serverId,
|
||||
label: HOST_LABEL,
|
||||
port: host.port,
|
||||
});
|
||||
const version = await readHelpHostVersion(page, getServerId());
|
||||
await expectHelpHostVersion(page, host.serverId, PREVIOUS_VERSION);
|
||||
await openHostPage(page, host);
|
||||
await expect(hostIdentity(page)).toContainText("Online");
|
||||
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
|
||||
await markPageForNoReloadCheck(page, NO_RELOAD_MARKER);
|
||||
return version;
|
||||
});
|
||||
|
||||
await test.step("the last known version remains on the host page when the host stops", async () => {
|
||||
await host.stop();
|
||||
await expect(hostIdentity(page)).toContainText("Error");
|
||||
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the host daemon restarts on the new version", async () => {
|
||||
await host.restartWithVersion(UPDATED_VERSION);
|
||||
});
|
||||
|
||||
await test.step("the host page badge follows the new version", async () => {
|
||||
await expect(hostVersionBadge(page, UPDATED_VERSION)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await expectHelpHostVersion(page, host.serverId, UPDATED_VERSION);
|
||||
expect(await readHelpHostVersion(page, getServerId())).toBe(otherHostVersion);
|
||||
await expectNoReloadSinceMarker(page);
|
||||
});
|
||||
});
|
||||
|
||||
async function readHelpHostVersion(page: Page, serverId: string): Promise<string> {
|
||||
await page.getByTestId("sidebar-help").click();
|
||||
const row = page.getByTestId(`sidebar-help-host-version-${serverId}`);
|
||||
await expect(row).toBeVisible();
|
||||
const text = await row.innerText();
|
||||
await page.keyboard.press("Escape");
|
||||
return text;
|
||||
}
|
||||
|
||||
async function expectHelpHostVersion(page: Page, serverId: string, version: string): Promise<void> {
|
||||
const text = await readHelpHostVersion(page, serverId);
|
||||
expect(text).toContain(`v${version}`);
|
||||
}
|
||||
|
||||
async function openHostPage(page: Page, host: RestartableHostDaemon): Promise<void> {
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, host.serverId);
|
||||
await openHostSection(page, host.serverId, "host");
|
||||
}
|
||||
|
||||
function hostIdentity(page: Page) {
|
||||
return page.getByTestId("host-page-identity");
|
||||
}
|
||||
|
||||
function hostVersionBadge(page: Page, version: string) {
|
||||
return hostIdentity(page).getByText(`v${version}`, { exact: true });
|
||||
}
|
||||
|
||||
async function markPageForNoReloadCheck(page: Page, marker: string): Promise<void> {
|
||||
// The marker has to cross into the page as an argument: the callback runs in the browser, so a
|
||||
// captured Node-side constant is not defined there.
|
||||
await page.evaluate((value) => {
|
||||
(window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker = value;
|
||||
}, marker);
|
||||
}
|
||||
|
||||
async function expectNoReloadSinceMarker(page: Page): Promise<void> {
|
||||
const marker = await page.evaluate(
|
||||
() => (window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker ?? null,
|
||||
);
|
||||
expect(marker).toBe(NO_RELOAD_MARKER);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { copyFile, mkdir, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { test, type Page } from "../support/fixtures";
|
||||
import { ImportSessionFlow } from "../support/helpers/import-session";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
@@ -49,7 +49,6 @@ interface ImportFlowScenario {
|
||||
worktreeDirectory: string;
|
||||
unrelatedDirectory: string;
|
||||
importSessionId: string;
|
||||
importSessionTimestamp: number;
|
||||
repoCleanup(): Promise<void>;
|
||||
unrelatedCleanup(): Promise<void>;
|
||||
}
|
||||
@@ -95,7 +94,7 @@ test.beforeAll(async () => {
|
||||
};
|
||||
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
|
||||
const importSessionId = "fixture-custom-title";
|
||||
const importSessionTimestamp = await seedClaudeSessions({
|
||||
await seedClaudeSessions({
|
||||
projectRoot: repo.path,
|
||||
worktreeDirectory,
|
||||
unrelatedDirectory: unrelated.path,
|
||||
@@ -109,7 +108,6 @@ test.beforeAll(async () => {
|
||||
worktreeDirectory,
|
||||
unrelatedDirectory: unrelated.path,
|
||||
importSessionId,
|
||||
importSessionTimestamp,
|
||||
repoCleanup: repo.cleanup,
|
||||
unrelatedCleanup: unrelated.cleanup,
|
||||
};
|
||||
@@ -124,19 +122,6 @@ test.afterAll(async () => {
|
||||
await rm(claudeConfigDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("an open Import Session row keeps its age current", async ({ page }) => {
|
||||
await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 });
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
||||
await flow.revealMobileEntryPoint();
|
||||
await flow.openGlobally();
|
||||
|
||||
const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`);
|
||||
await expect(row).toContainText("1m ago");
|
||||
await page.clock.fastForward("03:00");
|
||||
await expect(row).toContainText("4m ago");
|
||||
});
|
||||
|
||||
test("captures the compact import-session journey", async ({ page }, testInfo) => {
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
||||
@@ -240,7 +225,7 @@ async function seedClaudeSessions(input: {
|
||||
worktreeDirectory: string;
|
||||
unrelatedDirectory: string;
|
||||
importSessionId: string;
|
||||
}): Promise<number> {
|
||||
}): Promise<void> {
|
||||
const sessions = [
|
||||
{
|
||||
cwd: input.projectRoot,
|
||||
@@ -305,5 +290,4 @@ async function seedClaudeSessions(input: {
|
||||
const timestamp = new Date(newest - index * 60_000);
|
||||
await utimes(sessionPath, timestamp, timestamp);
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { launchAgent, cleanupRewindFlow, type AgentHandle } from "../support/helpers/rewind-flow";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
|
||||
const binary = process.env.OPENCODE_TEST_BINARY ?? "opencode";
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: { providers: { opencode: { enabled: true, command: [binary] } } },
|
||||
},
|
||||
});
|
||||
|
||||
async function selectMedium(page: Page) {
|
||||
await page
|
||||
.getByRole("button", { name: /^Select thinking option/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page.getByText("Medium", { exact: true }).filter({ visible: true }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Select thinking option (Medium)", exact: true })
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function selectLongcat(page: Page) {
|
||||
await page
|
||||
.getByRole("button", { name: /^Select model \(/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page
|
||||
.getByText("LongCat 2.5 Preview Free", { exact: true })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
}
|
||||
async function askForReply(handle: AgentHandle) {
|
||||
await submitMessage(handle.page, "Reply exactly OK. Do not use tools.");
|
||||
return handle.client.waitForFinish(handle.agentId, 120_000);
|
||||
}
|
||||
|
||||
test("switching an existing OpenCode session clears unsupported thinking", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(240_000);
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-variant-ui-"));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
cwd,
|
||||
provider: "opencode",
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "opencode/space-bunny-free" },
|
||||
});
|
||||
await selectMedium(page);
|
||||
expect((await askForReply(handle)).status).toBe("idle");
|
||||
await selectLongcat(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Select thinking option/ }).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
const finish = await askForReply(handle);
|
||||
const screenshot = testInfo.outputPath("model-switch.png");
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach("After switching to Longcat", {
|
||||
path: screenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeFalsy();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ visible: true }).last(),
|
||||
).toContainText("OK");
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
@@ -119,25 +119,6 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("provider settings overlay stack", () => {
|
||||
test("provider model update age says just now", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-relative-time-",
|
||||
title: "Provider relative time",
|
||||
});
|
||||
try {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await openProviderSettingsFromModelSelector(page);
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("provider settings covers the desktop model selector without closing it", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { buildSchedulesRoute } from "../../src/utils/host-routes";
|
||||
|
||||
interface ScheduleSeedClient {
|
||||
scheduleCreate(input: {
|
||||
prompt: string;
|
||||
name?: string;
|
||||
cadence: { type: "cron"; expression: string };
|
||||
target: {
|
||||
type: "new-agent";
|
||||
config: {
|
||||
provider: "mock";
|
||||
cwd: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
runOnCreate: boolean;
|
||||
}): Promise<{ schedule: { id: string; createdAt: string } | null; error: string | null }>;
|
||||
schedulePause(input: { id: string }): Promise<{ error: string | null }>;
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
interface SeededSchedule {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
async function seedPausedSchedule(
|
||||
workspace: SeededWorkspace,
|
||||
name: string,
|
||||
): Promise<SeededSchedule> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const result = await client.scheduleCreate({
|
||||
prompt: "Say hello from the scheduled agent.",
|
||||
name,
|
||||
cadence: { type: "cron", expression: "0 9 * * *" },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
title: name,
|
||||
},
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
if (!result.schedule) {
|
||||
throw new Error(result.error ?? "Failed to seed schedule");
|
||||
}
|
||||
const { id, createdAt } = result.schedule;
|
||||
const paused = await client.schedulePause({ id });
|
||||
if (paused.error) {
|
||||
throw new Error(paused.error);
|
||||
}
|
||||
return { id, createdAt: Date.parse(createdAt) };
|
||||
}
|
||||
|
||||
async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise<void> {
|
||||
const client = workspace.client as unknown as ScheduleSeedClient;
|
||||
const result = await client.scheduleDelete({ id });
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens Schedules with the browser clock frozen at `time`, so the page cannot age it. */
|
||||
async function openSchedulesAt(page: Page, time: number): Promise<void> {
|
||||
await page.clock.install({ time });
|
||||
await page.goto(buildSchedulesRoute());
|
||||
}
|
||||
|
||||
async function letTimePass(page: Page, duration: string): Promise<void> {
|
||||
await page.clock.fastForward(duration);
|
||||
}
|
||||
|
||||
async function expectScheduleRowText(page: Page, scheduleId: string, text: string): Promise<void> {
|
||||
await expect(page.getByTestId(`schedule-row-${scheduleId}`)).toContainText(text, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Schedule relative timestamps", () => {
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
const failures: unknown[] = [];
|
||||
for (const cleanup of cleanupTasks.toReversed()) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
cleanupTasks.length = 0;
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, "Test cleanup failed");
|
||||
}
|
||||
});
|
||||
|
||||
test("an idle schedule row keeps its created age current", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-relative-time-", git: false });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const schedule = await seedPausedSchedule(workspace, `Relative time ${Date.now()}`);
|
||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, schedule.id));
|
||||
|
||||
await openSchedulesAt(page, schedule.createdAt);
|
||||
await expectScheduleRowText(page, schedule.id, "Created just now");
|
||||
|
||||
await letTimePass(page, "03:00");
|
||||
|
||||
await expectScheduleRowText(page, schedule.id, "Created 3m ago");
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
|
||||
|
||||
const SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
async function openParentInRightPane(page: Page, parentId: string, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId, agentId: parentId });
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${parentId}`)).toBeVisible();
|
||||
await page.getByTestId("workspace-pane-main").getByTestId("workspace-new-tab-button").click();
|
||||
await page.getByTestId("workspace-new-tab-menu-agent").click();
|
||||
await page.getByTestId(`workspace-tab-agent_${parentId}`).click();
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page
|
||||
.getByTestId("workspace-pane-main")
|
||||
.getByTestId(`workspace-tab-agent_${parentId}`)
|
||||
.click();
|
||||
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
|
||||
|
||||
const left = page.getByTestId("workspace-pane-main");
|
||||
const right = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId(`workspace-tab-agent_${parentId}`) });
|
||||
await expect(left).toBeVisible();
|
||||
await expect(right).toBeVisible();
|
||||
await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||
await left.locator('[data-testid^="workspace-tab-draft_"]').click();
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||
});
|
||||
});
|
||||
|
||||
test("a subagent opened from a right-pane parent stays with its parent when left has focus", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-origin-pane-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the side preference opens a right-pane parent's subagent in the side pane", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((key) => {
|
||||
localStorage.setItem(key, JSON.stringify({ openInSidePane: { subagents: true } }));
|
||||
}, SETTINGS_KEY);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-side-preference-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
const childPane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId(`workspace-tab-agent_${pair.child.id}`) });
|
||||
await expect(childPane).toBeVisible();
|
||||
expect(await childPane.getAttribute("data-testid")).not.toBe(
|
||||
await right.getAttribute("data-testid"),
|
||||
);
|
||||
await expect(childPane).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening an already-tabbed subagent reveals its left tab without a duplicate", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-existing-tab-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await page.keyboard.press("Meta+Alt+Shift+ArrowLeft");
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await right.getByTestId(`workspace-tab-agent_${pair.parent.id}`).click();
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
|
||||
const target = "linked.ts:3";
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-file-origin-pane-",
|
||||
title: "File link parent",
|
||||
initialPrompt: [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n"),
|
||||
});
|
||||
try {
|
||||
await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
|
||||
const { left, right } = await openParentInRightPane(
|
||||
page,
|
||||
workspace.agentId,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
const fileLink = right.getByText(target, { exact: true });
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
await expect(right.getByTestId("workspace-tab-file_linked.ts")).toBeVisible();
|
||||
await expect(left.getByTestId("workspace-tab-file_linked.ts")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { openSettings } from "./app";
|
||||
import { openSettingsSection } from "./settings";
|
||||
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
|
||||
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { loadSessionMessageReaders } from "./new-workspace";
|
||||
|
||||
export async function withStreamingMarkdownOutline(
|
||||
run: (agent: MockAgentWorkspace) => Promise<void>,
|
||||
@@ -230,22 +229,3 @@ async function requireBoundingBox(
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
export async function observePromptIndexRequests(page: Page) {
|
||||
const frames = await loadSessionMessageReaders();
|
||||
const agentIds: string[] = [];
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
const request = frames.client(payload);
|
||||
if (request?.type === "agent.timeline.list_prompts.request") {
|
||||
agentIds.push(request.agentId);
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
async waitForRequestFor(agentId: string) {
|
||||
await expect.poll(() => agentIds).toContain(agentId);
|
||||
},
|
||||
requestedAgentIds: () => [...new Set(agentIds)],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,12 +206,6 @@ export async function createCreationScenario(page: Page) {
|
||||
)
|
||||
.toBe(count);
|
||||
},
|
||||
async agentIds() {
|
||||
await requests.settled();
|
||||
return (await project.client.fetchAgents()).entries
|
||||
.filter(({ agent }) => agent.workspaceId === workspaceId)
|
||||
.map(({ agent }) => agent.id);
|
||||
},
|
||||
async expectAgentTitle(title: string) {
|
||||
await expect
|
||||
.poll(
|
||||
|
||||
@@ -41,13 +41,6 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAvailableHostDaemonPort(): Promise<number> {
|
||||
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
|
||||
let port = await getAvailablePort();
|
||||
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
const deadline = Date.now() + 90_000;
|
||||
let lastError: unknown = null;
|
||||
@@ -88,7 +81,9 @@ export async function startIsolatedHostDaemon(
|
||||
serverId: string,
|
||||
options: IsolatedHostDaemonOptions = {},
|
||||
): Promise<IsolatedHostDaemon> {
|
||||
const port = await getAvailableHostDaemonPort();
|
||||
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
|
||||
let port = await getAvailablePort();
|
||||
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
|
||||
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { getAvailableHostDaemonPort } from "./isolated-host-daemon";
|
||||
import { killProcessTree, spawnTsx } from "./spawn-node";
|
||||
|
||||
const VERSIONED_DAEMON_ENTRYPOINT = path.resolve(
|
||||
__dirname,
|
||||
"../../../../server/src/server/test-utils/versioned-daemon.ts",
|
||||
);
|
||||
|
||||
const READY_TIMEOUT_MS = 30_000;
|
||||
|
||||
// A host daemon that can be replaced by a daemon reporting another version under the same
|
||||
// endpoint and serverId, the way an upgraded host comes back.
|
||||
export interface RestartableHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
stop(): Promise<void>;
|
||||
restartWithVersion(version: string): Promise<void>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function startRestartableHostDaemon(version: string): Promise<RestartableHostDaemon> {
|
||||
const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-e2e-restartable-host-"));
|
||||
const port = await getAvailableHostDaemonPort();
|
||||
let current: VersionedHostDaemon | null = null;
|
||||
async function dispose(): Promise<void> {
|
||||
await current?.stop();
|
||||
current = null;
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
}
|
||||
try {
|
||||
current = await startVersionedHostDaemon({ version, port, paseoHomeRoot });
|
||||
} catch (error) {
|
||||
await dispose();
|
||||
throw error;
|
||||
}
|
||||
const { serverId } = current;
|
||||
|
||||
return {
|
||||
serverId,
|
||||
port,
|
||||
async stop() {
|
||||
await current?.stop();
|
||||
current = null;
|
||||
await waitForPortReleased(port);
|
||||
},
|
||||
async restartWithVersion(nextVersion) {
|
||||
await current?.stop();
|
||||
current = null;
|
||||
await waitForPortReleased(port);
|
||||
current = await startVersionedHostDaemon({ version: nextVersion, port, paseoHomeRoot });
|
||||
if (current.serverId !== serverId) {
|
||||
throw new Error(
|
||||
`Restarted host daemon reported serverId ${current.serverId}, expected ${serverId}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
interface VersionedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
version: string;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
interface VersionedHostDaemonOptions {
|
||||
version: string;
|
||||
port: number;
|
||||
paseoHomeRoot: string;
|
||||
}
|
||||
|
||||
// Starts the versioned test daemon on an explicit port with a caller-owned PASEO_HOME. The caller
|
||||
// removes the home root.
|
||||
async function startVersionedHostDaemon(
|
||||
options: VersionedHostDaemonOptions,
|
||||
): Promise<VersionedHostDaemon> {
|
||||
const child = spawnTsx(
|
||||
VERSIONED_DAEMON_ENTRYPOINT,
|
||||
[options.version, String(options.port), options.paseoHomeRoot],
|
||||
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
|
||||
);
|
||||
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
|
||||
let output = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString("utf8");
|
||||
});
|
||||
child.once("error", ready.reject);
|
||||
child.once("exit", () => {
|
||||
ready.reject(
|
||||
new Error(`Versioned daemon ${options.version} exited before reporting ready:\n${output}`),
|
||||
);
|
||||
});
|
||||
child.once("message", (message) => {
|
||||
if (!isReadyMessage(message)) {
|
||||
ready.reject(new Error("Invalid versioned daemon startup message"));
|
||||
return;
|
||||
}
|
||||
ready.resolve(message);
|
||||
});
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
ready.reject(
|
||||
new Error(
|
||||
`Versioned daemon ${options.version} did not report ready within ${READY_TIMEOUT_MS}ms:\n${output}`,
|
||||
),
|
||||
),
|
||||
READY_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
let stopped = false;
|
||||
async function stop(): Promise<void> {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
await killProcessTree(child);
|
||||
}
|
||||
|
||||
try {
|
||||
const readyMessage = await ready.promise;
|
||||
if (readyMessage.port !== options.port) {
|
||||
throw new Error(
|
||||
`Versioned daemon ${options.version} bound port ${readyMessage.port}, expected ${options.port}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
serverId: readyMessage.serverId,
|
||||
port: readyMessage.port,
|
||||
version: options.version,
|
||||
stop,
|
||||
};
|
||||
} catch (error) {
|
||||
await stop();
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
// A replacement daemon can only bind the port the stopped daemon just released.
|
||||
async function waitForPortReleased(port: number, timeoutMs = 15_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await isPortAvailable(port)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`Port ${port} was not released within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
function isReadyMessage(message: unknown): message is { port: number; serverId: string } {
|
||||
return (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"port" in message &&
|
||||
typeof message.port === "number" &&
|
||||
"serverId" in message &&
|
||||
typeof message.serverId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ describe("chat outline prompt index epoch", () => {
|
||||
it("accepts only the authoritative timeline epoch", () => {
|
||||
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true);
|
||||
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-1")).toBe(false);
|
||||
expect(shouldAcceptPromptIndexEpoch(null, "epoch-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/
|
||||
|
||||
export type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number];
|
||||
|
||||
export function shouldAcceptPromptIndexEpoch(timelineEpoch: string, indexEpoch: string): boolean {
|
||||
return timelineEpoch === indexEpoch;
|
||||
export function shouldAcceptPromptIndexEpoch(
|
||||
timelineEpoch: string | null,
|
||||
indexEpoch: string,
|
||||
): boolean {
|
||||
return timelineEpoch === null || timelineEpoch === indexEpoch;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,31 +37,6 @@ describe("useChatOutline", () => {
|
||||
runtime.subscribeAgentTimeline.mockClear();
|
||||
});
|
||||
|
||||
it("waits for a served timeline before requesting its prompt index", async () => {
|
||||
runtime.listAgentTimelinePrompts.mockResolvedValue({ epoch: "epoch-1", prompts: [] });
|
||||
const viewportRef = createRef<StreamViewportHandle>();
|
||||
const { rerender } = renderHook(
|
||||
({ agentId, timelineEpoch }) =>
|
||||
useChatOutline({
|
||||
agentId,
|
||||
serverId: "server-1",
|
||||
timelineEpoch,
|
||||
tail: [],
|
||||
head: [],
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
}),
|
||||
{ initialProps: { agentId: "draft_msg_1", timelineEpoch: null as string | null } },
|
||||
);
|
||||
|
||||
expect(runtime.listAgentTimelinePrompts).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ agentId: "agent-1", timelineEpoch: "epoch-1" });
|
||||
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1));
|
||||
expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledWith("agent-1");
|
||||
});
|
||||
|
||||
it("drops a late prompt index after the authoritative timeline epoch changes", async () => {
|
||||
const first = deferred<{ epoch: string; prompts: [] }>();
|
||||
const second = deferred<{
|
||||
|
||||
@@ -77,10 +77,8 @@ export function useChatOutline({
|
||||
|
||||
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
|
||||
|
||||
// Only a timeline the daemon has served can be indexed. A draft's optimistic stream has no
|
||||
// epoch, and its id names no agent the daemon knows.
|
||||
useEffect(() => {
|
||||
if (!isWeb || !enabled || timelineEpoch === null) {
|
||||
if (!isWeb || !enabled) {
|
||||
setIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -210,17 +210,6 @@ describe("stream presentation through installed plugins", () => {
|
||||
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
|
||||
});
|
||||
|
||||
// Codex streams a line break and the next line's indent as their own chunks.
|
||||
it("keeps a line break when a chunk ends in the next line's indent", () => {
|
||||
const harness = streamHarness();
|
||||
harness.send(assistant("```mermaid\nflowchart TD\n A[Start] --> B[Review]\n"));
|
||||
harness.send(assistant(" "));
|
||||
const result = harness.send(assistant(" B --> C[Finish]\n```"));
|
||||
expect(result.head).toMatchObject([
|
||||
{ text: "```mermaid\nflowchart TD\n A[Start] --> B[Review]\n B --> C[Finish]\n```" },
|
||||
]);
|
||||
});
|
||||
|
||||
// One rendering path: a fetched message is the same block group as a streamed one,
|
||||
// which is what lets find, scroll-to-message and history reveal address it by id.
|
||||
// A link reference definition stays with the paragraph that uses it, so the split
|
||||
|
||||
@@ -112,10 +112,8 @@ export function createStreamPresentation() {
|
||||
const index = prefix.length + offset;
|
||||
let blockText = text;
|
||||
if (offset === textBlocks.length - 1) {
|
||||
// The split drops trailing blank lines, including a line break followed only
|
||||
// by the next line's indent. Keep them so the next append continues that line.
|
||||
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
|
||||
blockText += trailingBlankLines;
|
||||
const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? "";
|
||||
blockText += trailingNewlines;
|
||||
}
|
||||
const existing = previous?.[index];
|
||||
const id = `${item.id}:block:${index}`;
|
||||
|
||||
@@ -10,7 +10,6 @@ import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
|
||||
import { useAssistantFileLinkResolverContext } from "./provider";
|
||||
import type { AssistantFileLinkSource } from "./resolver";
|
||||
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
||||
import { useFileLink } from "./use-file-link";
|
||||
|
||||
interface AssistantMarkdownLinkProps {
|
||||
@@ -35,7 +34,7 @@ export function AssistantMarkdownLink({
|
||||
const { configRef } = useAssistantFileLinkResolverContext();
|
||||
const workspaceRoot = configRef.current.workspaceRoot;
|
||||
const tooltipPath = useMemo(
|
||||
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
|
||||
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
|
||||
[target, workspaceRoot],
|
||||
);
|
||||
const linkPress = useMemo<AssistantLinkPress>(
|
||||
@@ -128,6 +127,38 @@ export function AssistantMarkdownCodeLink({
|
||||
);
|
||||
}
|
||||
|
||||
function formatInlinePathTargetForTooltip(
|
||||
target: { path: string; lineStart?: number; lineEnd?: number },
|
||||
workspaceRoot: string | undefined,
|
||||
): string {
|
||||
let result = relativizePathToWorkspace(target.path, workspaceRoot);
|
||||
if (target.lineStart) {
|
||||
result += `:${target.lineStart}`;
|
||||
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
||||
result += `-${target.lineEnd}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
|
||||
if (!workspaceRoot) {
|
||||
return filePath;
|
||||
}
|
||||
const root = workspaceRoot.replace(/\/+$/, "");
|
||||
if (!root) {
|
||||
return filePath;
|
||||
}
|
||||
if (filePath === root) {
|
||||
return ".";
|
||||
}
|
||||
const prefix = `${root}/`;
|
||||
if (filePath.startsWith(prefix)) {
|
||||
return filePath.slice(prefix.length);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
interface AssistantInlineCodePathLinkProps {
|
||||
content: string;
|
||||
inheritedStyles: TextStyle;
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
||||
|
||||
describe("formatFileLinkTooltipPath", () => {
|
||||
it("shows a Windows file path relative to its workspace regardless of separators", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: {
|
||||
path: "C:/Users/me/repo/src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
},
|
||||
workspaceRoot: "C:\\Users\\me\\repo",
|
||||
}),
|
||||
).toBe("src/app.ts:12-20");
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "C:/src/app.ts", lineStart: 12 },
|
||||
workspaceRoot: "C:\\",
|
||||
}),
|
||||
).toBe("src/app.ts:12");
|
||||
});
|
||||
|
||||
it("shows the workspace root as a dot", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "/Users/me/repo" },
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toBe(".");
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "C:/Users/Me/Repo/", lineStart: 4 },
|
||||
workspaceRoot: "c:\\users\\me\\repo",
|
||||
}),
|
||||
).toBe(".:4");
|
||||
});
|
||||
|
||||
it("keeps an absolute path outside the workspace", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "/Users/me/notes.md" },
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toBe("/Users/me/notes.md");
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "C:/Users/me/notes.md", lineStart: 8 },
|
||||
workspaceRoot: "C:\\Users\\me\\repo",
|
||||
}),
|
||||
).toBe("C:/Users/me/notes.md:8");
|
||||
});
|
||||
|
||||
it("keeps the target path when the workspace root is unavailable", () => {
|
||||
expect(formatFileLinkTooltipPath({ target: { path: "src/app.ts", lineStart: 12 } })).toBe(
|
||||
"src/app.ts:12",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import {
|
||||
isWorkspaceRootPath,
|
||||
resolveWorkspaceFilePaths,
|
||||
type WorkspaceFileLocation,
|
||||
} from "@/workspace/file-open";
|
||||
|
||||
interface FormatFileLinkTooltipPathInput {
|
||||
target: WorkspaceFileLocation;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export function formatFileLinkTooltipPath({
|
||||
target,
|
||||
workspaceRoot,
|
||||
}: FormatFileLinkTooltipPathInput): string {
|
||||
const resolvedPaths = workspaceRoot
|
||||
? resolveWorkspaceFilePaths({ path: target.path, workspaceRoot })
|
||||
: null;
|
||||
const isWorkspaceRoot = workspaceRoot
|
||||
? isWorkspaceRootPath({ path: target.path, workspaceRoot })
|
||||
: false;
|
||||
let result = isWorkspaceRoot ? "." : (resolvedPaths?.relativePath ?? target.path);
|
||||
if (target.lineStart) {
|
||||
result += `:${target.lineStart}`;
|
||||
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
||||
result += `-${target.lineEnd}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive, ChevronRight } from "lucide-react-native";
|
||||
@@ -181,6 +181,7 @@ function SessionRow({
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const projectName = agent.projectPlacement?.projectName ?? "";
|
||||
@@ -282,7 +283,7 @@ function SessionRow({
|
||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||
/>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<AgentActivityTime date={agent.lastActivityAt} isMobile />
|
||||
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
|
||||
{showHostColumn && agent.serverLabel ? (
|
||||
<>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
@@ -315,7 +316,9 @@ function SessionRow({
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||
/>
|
||||
<AgentActivityTime date={agent.lastActivityAt} isMobile={false} />
|
||||
<Text style={styles.columnMetaFixed} numberOfLines={1}>
|
||||
{timeAgo}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<SessionRowTrailingAttention
|
||||
@@ -327,15 +330,6 @@ function SessionRow({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentActivityTime({ date, isMobile }: { date: Date; isMobile: boolean }) {
|
||||
const label = useTimeAgo(date);
|
||||
return (
|
||||
<Text style={isMobile ? styles.sessionMetaText : styles.columnMetaFixed} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentList({
|
||||
agents,
|
||||
isRefreshing = false,
|
||||
|
||||
@@ -117,6 +117,7 @@ export function ContextWindowMeter({
|
||||
width={geometry.svgSize}
|
||||
height={geometry.svgSize}
|
||||
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
|
||||
style={styles.svg}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
@@ -157,6 +158,7 @@ export function ContextWindowMeter({
|
||||
width={svgSize}
|
||||
height={svgSize}
|
||||
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
||||
style={styles.svg}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
@@ -178,8 +180,6 @@ export function ContextWindowMeter({
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
</Svg>
|
||||
{showPercentage ? (
|
||||
@@ -226,6 +226,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
svg: {
|
||||
transform: [{ rotate: "-90deg" }],
|
||||
},
|
||||
percentageLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
@@ -309,6 +309,7 @@ function ImportSessionSheetRow({
|
||||
const { t } = useTranslation();
|
||||
const title = getSessionTitle(entry);
|
||||
const promptPreview = getPromptPreview(entry);
|
||||
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
|
||||
const ProviderIcon = getProviderIcon(entry.providerId, serverId);
|
||||
const accessibilityState = useMemo(
|
||||
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
|
||||
@@ -343,11 +344,9 @@ function ImportSessionSheetRow({
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{importing ? (
|
||||
<Text style={styles.rowMeta}>{t("importSession.row.importing")}</Text>
|
||||
) : (
|
||||
<ImportSessionActivityTime date={entry.lastActivityAt} />
|
||||
)}
|
||||
<Text style={styles.rowMeta}>
|
||||
{importing ? t("importSession.row.importing") : lastActivity}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.rowPreview} numberOfLines={2}>
|
||||
{promptPreview}
|
||||
@@ -366,11 +365,6 @@ function ImportSessionSheetRow({
|
||||
);
|
||||
}
|
||||
|
||||
function ImportSessionActivityTime({ date }: { date: string }) {
|
||||
const label = useTimeAgo(new Date(date));
|
||||
return <Text style={styles.rowMeta}>{label}</Text>;
|
||||
}
|
||||
|
||||
function SessionRows({
|
||||
serverId,
|
||||
entries,
|
||||
|
||||
@@ -19,10 +19,10 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
|
||||
@@ -409,7 +409,7 @@ interface ProviderModalBodyProps {
|
||||
}
|
||||
|
||||
interface ProviderSheetFooterInput {
|
||||
fetchedAt: string | null;
|
||||
fetchedAtLabel: string | null;
|
||||
isCompact: boolean;
|
||||
modelsRefreshing: boolean;
|
||||
t: TFunction;
|
||||
@@ -419,7 +419,7 @@ interface ProviderSheetFooterInput {
|
||||
}
|
||||
|
||||
function renderProviderSheetFooter({
|
||||
fetchedAt,
|
||||
fetchedAtLabel,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
@@ -430,10 +430,16 @@ function renderProviderSheetFooter({
|
||||
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
|
||||
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
|
||||
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
|
||||
const metaStyle = isCompact
|
||||
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
|
||||
: sheetStyles.footerMeta;
|
||||
|
||||
return (
|
||||
<View style={contentStyle}>
|
||||
{fetchedAt || !isCompact ? (
|
||||
<ProviderUpdatedLabel fetchedAt={fetchedAt} isCompact={isCompact} />
|
||||
{fetchedAtLabel || !isCompact ? (
|
||||
<Text style={metaStyle} numberOfLines={1}>
|
||||
{fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={actionsStyle}>
|
||||
<Button
|
||||
@@ -471,25 +477,6 @@ function renderProviderSheetFooter({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderUpdatedLabel({
|
||||
fetchedAt,
|
||||
isCompact,
|
||||
}: {
|
||||
fetchedAt: string | null;
|
||||
isCompact: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = useTimeAgo(fetchedAt ? new Date(fetchedAt) : null);
|
||||
const style = isCompact
|
||||
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
|
||||
: sheetStyles.footerMeta;
|
||||
return (
|
||||
<Text style={style} numberOfLines={1}>
|
||||
{fetchedAt ? t("settings.providers.models.updated", { time: label }) : ""}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -622,6 +609,18 @@ export function ProviderDiagnosticSheet({
|
||||
});
|
||||
stableDiscoveredRef.current = nextDiscoveredCache;
|
||||
|
||||
const [clockTick, setClockTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const id = setInterval(() => setClockTick((tick) => tick + 1), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [visible]);
|
||||
const fetchedAtLabel = useMemo(() => {
|
||||
if (!providerEntry?.fetchedAt) return null;
|
||||
void clockTick;
|
||||
return formatTimeAgo(new Date(providerEntry.fetchedAt));
|
||||
}, [providerEntry?.fetchedAt, clockTick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setQuery("");
|
||||
@@ -687,7 +686,7 @@ export function ProviderDiagnosticSheet({
|
||||
onClose={onClose}
|
||||
testID="provider-settings-sheet"
|
||||
footer={renderProviderSheetFooter({
|
||||
fetchedAt: providerEntry?.fetchedAt ?? null,
|
||||
fetchedAtLabel,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
resolveScheduleTitle,
|
||||
scheduleProductName,
|
||||
} from "@/utils/schedule-format";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
|
||||
@@ -96,19 +96,16 @@ function stateBadge(state: ScheduleDerivedState): {
|
||||
// Meta reads left-to-right as identity → history → future: how often, when it
|
||||
// was created, when it last ran, and (only while it can still run) when it runs
|
||||
// next. Status lives on the badge, never repeated here.
|
||||
function buildMeta(input: {
|
||||
schedule: ScheduleSummary;
|
||||
state: ScheduleDerivedState;
|
||||
createdAgo: string;
|
||||
lastRunAgo: string;
|
||||
serverName: string | undefined;
|
||||
singleHost: boolean;
|
||||
}): string {
|
||||
const { schedule, state, serverName, singleHost } = input;
|
||||
function buildMeta(
|
||||
schedule: ScheduleSummary,
|
||||
state: ScheduleDerivedState,
|
||||
serverName: string | undefined,
|
||||
singleHost: boolean,
|
||||
): string {
|
||||
const parts = [
|
||||
formatCadence(schedule.cadence),
|
||||
`Created ${input.createdAgo}`,
|
||||
schedule.lastRunAt ? `Last run ${input.lastRunAgo}` : "Never run",
|
||||
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
|
||||
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
|
||||
];
|
||||
if (state === "active") {
|
||||
const next = formatNextRun(schedule.nextRunAt);
|
||||
@@ -122,27 +119,6 @@ function buildMeta(input: {
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function ScheduleMeta({
|
||||
schedule,
|
||||
state,
|
||||
serverName,
|
||||
singleHost,
|
||||
}: {
|
||||
schedule: ScheduleSummary;
|
||||
state: ScheduleDerivedState;
|
||||
serverName: string | undefined;
|
||||
singleHost: boolean;
|
||||
}) {
|
||||
const createdAgo = useTimeAgo(new Date(schedule.createdAt));
|
||||
const lastRunAgo = useTimeAgo(schedule.lastRunAt ? new Date(schedule.lastRunAt) : null);
|
||||
const meta = buildMeta({ schedule, state, createdAgo, lastRunAgo, serverName, singleHost });
|
||||
return (
|
||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||
{meta}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
||||
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
|
||||
function ProviderGlyph({
|
||||
@@ -192,6 +168,7 @@ export function ScheduleRow({
|
||||
const title = resolveScheduleTitle(schedule);
|
||||
const productName = scheduleProductName(schedule);
|
||||
const badge = stateBadge(state);
|
||||
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
|
||||
const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused");
|
||||
|
||||
const rowStyle = useCallback(
|
||||
@@ -229,12 +206,9 @@ export function ScheduleRow({
|
||||
<Text style={styles.target} numberOfLines={1}>
|
||||
{targetLabel}
|
||||
</Text>
|
||||
<ScheduleMeta
|
||||
schedule={schedule}
|
||||
state={state}
|
||||
serverName={serverName}
|
||||
singleHost={singleHost ?? false}
|
||||
/>
|
||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||
{meta}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DiffStat } from "@/components/diff-stat";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
|
||||
|
||||
export type { SidebarWorkspaceTrailing };
|
||||
|
||||
|
||||
@@ -462,7 +462,7 @@ export function WorkspaceSetupDialog() {
|
||||
cwd={sourceDirectory}
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraft={composerState?.commandDraft}
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
inputWrapperStyle={styles.composerInputWrapper}
|
||||
/>
|
||||
|
||||
@@ -198,15 +198,12 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
|
||||
expect(getLatest().composerState?.commandDraft).toEqual({
|
||||
status: "ready",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
},
|
||||
expect(getLatest().composerState?.commandDraftConfig).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
const hydratedTextReplacement = getLatest().textReplacement;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasDraftContent, resolveDraftKey } from "./input-draft-core";
|
||||
import {
|
||||
buildDraftCommandTarget,
|
||||
buildDraftCommandConfig,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
@@ -124,55 +124,46 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDraftCommandTarget", () => {
|
||||
const codexSelection = {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
thinkingOptionId: "high",
|
||||
availableModels: [],
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
};
|
||||
|
||||
it("needs a project when cwd is empty", () => {
|
||||
describe("buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
expect(
|
||||
buildDraftCommandTarget({
|
||||
selection: codexSelection,
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
modeOptions: [],
|
||||
},
|
||||
cwd: " ",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toEqual({ status: "needs-project" });
|
||||
});
|
||||
|
||||
it("needs a provider when none is selected", () => {
|
||||
expect(
|
||||
buildDraftCommandTarget({
|
||||
selection: { ...codexSelection, provider: null },
|
||||
cwd: "/repo",
|
||||
effectiveModelId: "",
|
||||
effectiveThinkingOptionId: "",
|
||||
}),
|
||||
).toEqual({ status: "needs-provider" });
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds the draft command config from derived composer state", () => {
|
||||
expect(
|
||||
buildDraftCommandTarget({
|
||||
selection: codexSelection,
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
thinkingOptionId: "high",
|
||||
availableModels: [],
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
},
|
||||
cwd: "/repo",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
status: "ready",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
},
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { TextReplacement } from "@/composer/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import {
|
||||
useAgentFormState,
|
||||
type CreateAgentInitialValues,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type DraftKeyInput,
|
||||
} from "@/composer/draft/input-draft-core";
|
||||
import {
|
||||
buildDraftCommandTarget,
|
||||
buildDraftCommandConfig,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type ProviderSelectionState,
|
||||
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
agentControls: DraftAgentControlsProps;
|
||||
commandDraft: DraftCommandTarget;
|
||||
commandDraftConfig: DraftCommandConfig | undefined;
|
||||
};
|
||||
|
||||
export interface AgentInputDraft {
|
||||
@@ -287,16 +287,19 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[applyProfileFeatureValues, formState],
|
||||
);
|
||||
|
||||
const commandDraft = useMemo(
|
||||
const commandDraftConfig = useMemo(
|
||||
() =>
|
||||
buildDraftCommandTarget({
|
||||
selection: providerSelection,
|
||||
cwd: workingDir,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
}),
|
||||
composerOptions
|
||||
? buildDraftCommandConfig({
|
||||
selection: providerSelection,
|
||||
cwd: workingDir,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
})
|
||||
: undefined,
|
||||
[
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatureValues,
|
||||
@@ -322,10 +325,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
onSetFeature: setDraftFeatureValue,
|
||||
onApplyAgentProfile: applyDraftAgentProfile,
|
||||
}),
|
||||
commandDraft,
|
||||
commandDraftConfig,
|
||||
};
|
||||
}, [
|
||||
commandDraft,
|
||||
commandDraftConfig,
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
@@ -355,6 +358,7 @@ export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftCommandTarget,
|
||||
buildDraftCommandConfig,
|
||||
buildDraftComposerCommandConfig: buildDraftCommandConfig,
|
||||
buildDraftAgentControls,
|
||||
};
|
||||
|
||||
@@ -682,7 +682,7 @@ export function WorkspaceDraftAgentTab({
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraft={composerState.commandDraft}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
|
||||
@@ -62,7 +62,7 @@ import {
|
||||
} from "./input/input";
|
||||
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import {
|
||||
@@ -973,8 +973,8 @@ interface ComposerProps {
|
||||
autoFocusKey?: string;
|
||||
/** Callback to expose a focus function to parent components (desktop only). */
|
||||
onFocusInput?: (focus: () => void) => void;
|
||||
/** Draft context for listing commands before an agent exists. Omitted for running agents. */
|
||||
commandDraft?: DraftCommandTarget;
|
||||
/** Optional draft context for listing commands before an agent exists. */
|
||||
commandDraftConfig?: DraftCommandConfig;
|
||||
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
||||
onMessageSent?: () => void;
|
||||
onComposerHeightChange?: (height: number) => void;
|
||||
@@ -1264,7 +1264,7 @@ function ComposerContentImpl({
|
||||
autoFocus = false,
|
||||
autoFocusKey,
|
||||
onFocusInput,
|
||||
commandDraft,
|
||||
commandDraftConfig,
|
||||
onMessageSent,
|
||||
onComposerHeightChange,
|
||||
onAttentionInputFocus,
|
||||
@@ -2346,7 +2346,7 @@ function ComposerContentImpl({
|
||||
setUserInput: replaceUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
draft: commandDraft,
|
||||
draftConfig: commandDraftConfig,
|
||||
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
|
||||
onClientSlashCommand: runClientSlashCommand,
|
||||
pluginClientSlashCommands,
|
||||
@@ -2355,7 +2355,7 @@ function ComposerContentImpl({
|
||||
replaceUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
commandDraft,
|
||||
commandDraftConfig,
|
||||
buildOutgoingAttachments,
|
||||
attachments,
|
||||
runClientSlashCommand,
|
||||
|
||||
@@ -32,7 +32,6 @@ import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts
|
||||
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
toDaemonServerInfo,
|
||||
useSessionStore,
|
||||
type SessionState,
|
||||
} from "@/stores/session-store";
|
||||
@@ -319,6 +318,24 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
[serverId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSessionServerInfo(serverId, {
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.desktopManaged !== undefined
|
||||
? { desktopManaged: serverInfo.desktopManaged }
|
||||
: {}),
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
}, [client, serverId, updateSessionServerInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = voiceRuntime?.registerSession({
|
||||
serverId,
|
||||
@@ -551,7 +568,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (message.type !== "status") return;
|
||||
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||
if (serverInfo) {
|
||||
updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo));
|
||||
updateSessionServerInfo(serverId, {
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.desktopManaged !== undefined
|
||||
? { desktopManaged: serverInfo.desktopManaged }
|
||||
: {}),
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,13 +4,14 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { CommitGraphNode } from "./commit-graph-node";
|
||||
|
||||
interface CommitRowProps {
|
||||
commit: ClassifiedCheckoutCommit;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
now: Date;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ export const CommitRow = memo(function CommitRow({
|
||||
commit,
|
||||
isFirst,
|
||||
isLast,
|
||||
now,
|
||||
onCommitPress,
|
||||
}: CommitRowProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -47,7 +49,7 @@ export const CommitRow = memo(function CommitRow({
|
||||
{commit.subject}
|
||||
</Text>
|
||||
</View>
|
||||
<CommitTimestamp authorDate={commit.authorDate} />
|
||||
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
|
||||
<View style={styles.caret}>
|
||||
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
@@ -55,11 +57,6 @@ export const CommitRow = memo(function CommitRow({
|
||||
);
|
||||
});
|
||||
|
||||
function CommitTimestamp({ authorDate }: { authorDate: string }) {
|
||||
const label = useTimeAgo(new Date(authorDate));
|
||||
return <Text style={styles.timestamp}>{label}</Text>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import { treeRowPaddingLeft } from "@/components/tree-primitives";
|
||||
@@ -39,9 +40,11 @@ function CommitsSectionSkeleton() {
|
||||
|
||||
function CommitsSectionContent({
|
||||
query,
|
||||
now,
|
||||
onCommitPress,
|
||||
}: {
|
||||
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
|
||||
now: Date;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -74,6 +77,7 @@ function CommitsSectionContent({
|
||||
commit={commit}
|
||||
isFirst={index === 0}
|
||||
isLast={index === workspaceCommits.length - 1}
|
||||
now={now}
|
||||
onCommitPress={onCommitPress}
|
||||
/>
|
||||
))}
|
||||
@@ -90,6 +94,9 @@ export function CommitsSection({
|
||||
}: CommitsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isPanelActive = useRetainedPanelActive();
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
|
||||
const query = useCheckoutCommitsQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -97,9 +104,20 @@ export function CommitsSection({
|
||||
});
|
||||
|
||||
const handleToggleSection = useCallback(() => {
|
||||
if (collapsed) {
|
||||
setNow(new Date());
|
||||
}
|
||||
onCollapsedChange?.(!collapsed);
|
||||
}, [collapsed, onCollapsedChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed || !isPanelActive) {
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => setNow(new Date()), 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [collapsed, isPanelActive]);
|
||||
|
||||
const headerChevronStyle = useMemo(
|
||||
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
|
||||
[collapsed],
|
||||
@@ -142,7 +160,9 @@ export function CommitsSection({
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
{collapsed ? null : <CommitsSectionContent query={query} onCommitPress={onCommitPress} />}
|
||||
{collapsed ? null : (
|
||||
<CommitsSectionContent query={query} now={displayNow} onCommitPress={onCommitPress} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useAgentCommandsQuery,
|
||||
type AgentSlashCommand,
|
||||
type DraftCommandConfig,
|
||||
type DraftCommandTarget,
|
||||
} from "./use-agent-commands-query";
|
||||
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||
import { useAutocomplete } from "./use-autocomplete";
|
||||
@@ -35,7 +34,7 @@ interface UseAgentAutocompleteInput {
|
||||
setUserInput: (nextValue: string) => void;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
draft?: DraftCommandTarget;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
onAutocompleteApplied?: () => void;
|
||||
onClientSlashCommand?: (command: ClientSlashCommand) => void;
|
||||
canExecuteClientSlashCommand?: boolean;
|
||||
@@ -118,13 +117,18 @@ type AvailableCommand =
|
||||
| { source: "plugin"; command: PluginClientSlashCommand }
|
||||
| { source: "provider"; command: AgentSlashCommand };
|
||||
|
||||
function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig | undefined {
|
||||
if (draft?.status !== "ready") {
|
||||
function normalizeDraftCommandConfig(
|
||||
draftConfig?: DraftCommandConfig,
|
||||
): DraftCommandConfig | undefined {
|
||||
if (!draftConfig) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const draftConfig = draft.config;
|
||||
const cwd = draftConfig.cwd.trim();
|
||||
if (!cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const modeId = draftConfig.modeId?.trim() ?? "";
|
||||
const model = draftConfig.model?.trim() ?? "";
|
||||
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
|
||||
@@ -333,35 +337,6 @@ function resolveAutocompleteErrorMessage(args: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// A draft's agentId is a draft key the daemon does not know, so a draft lists
|
||||
// commands by its config only, and not at all until that config is complete.
|
||||
function resolveDraftCommandContext(draft: DraftCommandTarget | undefined) {
|
||||
const queryDraftConfig = resolveDraftQueryConfig(draft);
|
||||
const isDraftContext = draft !== undefined;
|
||||
return {
|
||||
isDraftContext,
|
||||
queryDraftConfig,
|
||||
canListCommands: !isDraftContext || queryDraftConfig !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAutocompleteEmptyText(args: {
|
||||
mode: AutocompleteMode;
|
||||
draft: DraftCommandTarget | undefined;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
if (args.mode === "file") {
|
||||
return args.t("agentAutocomplete.noFiles");
|
||||
}
|
||||
if (args.draft?.status === "needs-project") {
|
||||
return args.t("agentAutocomplete.chooseProjectForCommands");
|
||||
}
|
||||
if (args.draft?.status === "needs-provider") {
|
||||
return args.t("agentAutocomplete.chooseModelForCommands");
|
||||
}
|
||||
return args.t("agentAutocomplete.noCommands");
|
||||
}
|
||||
|
||||
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -370,7 +345,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
setUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
draft,
|
||||
draftConfig,
|
||||
onAutocompleteApplied,
|
||||
onClientSlashCommand,
|
||||
canExecuteClientSlashCommand,
|
||||
@@ -405,10 +380,13 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
return () => clearTimeout(timer);
|
||||
}, [fileFilterQuery]);
|
||||
|
||||
const { isDraftContext, queryDraftConfig, canListCommands } = useMemo(
|
||||
() => resolveDraftCommandContext(draft),
|
||||
[draft],
|
||||
const normalizedDraftConfig = useMemo(
|
||||
() => normalizeDraftCommandConfig(draftConfig),
|
||||
[draftConfig],
|
||||
);
|
||||
|
||||
const isDraftContext = normalizedDraftConfig !== undefined;
|
||||
const queryDraftConfig = normalizedDraftConfig;
|
||||
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
|
||||
|
||||
const agentCwd = useSessionStore(
|
||||
@@ -432,14 +410,17 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
autocompleteCwd,
|
||||
});
|
||||
|
||||
const commandsQuery = useAgentCommandsQuery({
|
||||
const {
|
||||
commands,
|
||||
isLoading: isCommandsLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useAgentCommandsQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled: mode === "command" && canLoadCommands && canListCommands,
|
||||
enabled: mode === "command" && canLoadCommands,
|
||||
draftConfig: queryDraftConfig,
|
||||
});
|
||||
const { commands, isError, error } = commandsQuery;
|
||||
const isCommandsLoading = canListCommands && commandsQuery.isLoading;
|
||||
|
||||
const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading);
|
||||
|
||||
@@ -606,7 +587,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
mode === "file"
|
||||
? t("agentAutocomplete.searchingWorkspace")
|
||||
: t("agentAutocomplete.loadingCommands");
|
||||
const emptyText = resolveAutocompleteEmptyText({ mode, draft, t });
|
||||
const emptyText =
|
||||
mode === "file" ? t("agentAutocomplete.noFiles") : t("agentAutocomplete.noCommands");
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
|
||||
@@ -17,12 +17,6 @@ export interface AgentSlashCommand {
|
||||
|
||||
export type DraftCommandConfig = AgentCommandsDraftConfig;
|
||||
|
||||
/** What a draft composer can list commands for, before an agent exists. */
|
||||
export type DraftCommandTarget =
|
||||
| { status: "ready"; config: DraftCommandConfig }
|
||||
| { status: "needs-project" }
|
||||
| { status: "needs-provider" };
|
||||
|
||||
interface ListAgentCommandsOptions {
|
||||
agentId: string;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
|
||||
+5
-17
@@ -1,11 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { subscribeToRelativeTimeTick, type TickResolution } from "@/utils/relative-time-ticker";
|
||||
import { describeCompactTimeAgo, describeTimeAgo, type RelativeTimeLabel } from "@/utils/time";
|
||||
|
||||
/** A prose relative timestamp ("5m ago") that keeps itself current. See `useCompactTimeAgo`. */
|
||||
export function useTimeAgo(date: Date | null): string {
|
||||
return useRelativeTimeLabel(date, describeTimeAgo);
|
||||
}
|
||||
import { describeCompactTimeAgo } from "@/utils/time";
|
||||
|
||||
/**
|
||||
* A compact relative timestamp that keeps itself current.
|
||||
@@ -24,14 +19,7 @@ export function useTimeAgo(date: Date | null): string {
|
||||
* never changes again.
|
||||
*/
|
||||
export function useCompactTimeAgo(date: Date | null): string {
|
||||
return useRelativeTimeLabel(date, describeCompactTimeAgo);
|
||||
}
|
||||
|
||||
function useRelativeTimeLabel(
|
||||
date: Date | null,
|
||||
describe: (date: Date) => RelativeTimeLabel,
|
||||
): string {
|
||||
const [label, setLabel] = useState(() => (date ? describe(date).label : ""));
|
||||
const [label, setLabel] = useState(() => (date ? describeCompactTimeAgo(date).label : ""));
|
||||
|
||||
// Keyed on the instant, not the Date object: the store parses a fresh Date on every payload, so
|
||||
// depending on identity would tear down and rebuild the subscription for an unchanged time.
|
||||
@@ -44,13 +32,13 @@ function useRelativeTimeLabel(
|
||||
}
|
||||
|
||||
const source = new Date(time);
|
||||
let current = describe(source);
|
||||
let current = describeCompactTimeAgo(source);
|
||||
setLabel(current.label);
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const handleTick = () => {
|
||||
const next = describe(source);
|
||||
const next = describeCompactTimeAgo(source);
|
||||
if (next.label !== current.label) {
|
||||
setLabel(next.label);
|
||||
}
|
||||
@@ -74,7 +62,7 @@ function useRelativeTimeLabel(
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [time, describe]);
|
||||
}, [time]);
|
||||
|
||||
return label;
|
||||
}
|
||||
@@ -1522,8 +1522,6 @@ export const ar: TranslationResources = {
|
||||
noFiles: "لم يتم العثور على ملفات أو أدلة",
|
||||
noCommands: "لم يتم العثور على أي أوامر",
|
||||
failedToLoad: "فشل التحميل",
|
||||
chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر",
|
||||
chooseModelForCommands: "اختر نموذجًا لعرض الأوامر",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "تعذر تحميل السجل الأقدم",
|
||||
|
||||
@@ -1547,8 +1547,6 @@ export const en = {
|
||||
noFiles: "No files or directories found",
|
||||
noCommands: "No commands found",
|
||||
failedToLoad: "Failed to load",
|
||||
chooseProjectForCommands: "Choose a project to see commands",
|
||||
chooseModelForCommands: "Select a model to see commands",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Couldn't load older history",
|
||||
|
||||
@@ -1568,8 +1568,6 @@ export const es: TranslationResources = {
|
||||
noFiles: "No se encontraron archivos ni directorios",
|
||||
noCommands: "No se encontraron comandos",
|
||||
failedToLoad: "No se pudo cargar",
|
||||
chooseProjectForCommands: "Elige un proyecto para ver los comandos",
|
||||
chooseModelForCommands: "Selecciona un modelo para ver los comandos",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "No se pudo cargar el historial anterior",
|
||||
|
||||
@@ -1572,8 +1572,6 @@ export const fr: TranslationResources = {
|
||||
noFiles: "Aucun fichier ou répertoire trouvé",
|
||||
noCommands: "Aucune commande trouvée",
|
||||
failedToLoad: "Échec du chargement",
|
||||
chooseProjectForCommands: "Choisissez un projet pour voir les commandes",
|
||||
chooseModelForCommands: "Sélectionnez un modèle pour voir les commandes",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Impossible de charger l'ancien historique",
|
||||
|
||||
@@ -1539,8 +1539,6 @@ export const ja: TranslationResources = {
|
||||
noFiles: "ファイルまたはディレクトリが見つかりません",
|
||||
noCommands: "コマンドが見つかりません",
|
||||
failedToLoad: "読み込みに失敗しました",
|
||||
chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください",
|
||||
chooseModelForCommands: "コマンドを表示するにはモデルを選択してください",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "古い履歴を読み込めませんでした",
|
||||
|
||||
@@ -1532,8 +1532,6 @@ export const ko: TranslationResources = {
|
||||
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
|
||||
noCommands: "명령을 찾을 수 없습니다",
|
||||
failedToLoad: "불러오지 못했습니다",
|
||||
chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요",
|
||||
chooseModelForCommands: "명령을 보려면 모델을 선택하세요",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "이전 기록을 불러올 수 없습니다",
|
||||
|
||||
@@ -1553,8 +1553,6 @@ export const ptBR: TranslationResources = {
|
||||
noFiles: "Nenhum arquivo ou diretório encontrado",
|
||||
noCommands: "Nenhum comando encontrado",
|
||||
failedToLoad: "Falha ao carregar",
|
||||
chooseProjectForCommands: "Escolha um projeto para ver os comandos",
|
||||
chooseModelForCommands: "Selecione um modelo para ver os comandos",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Não foi possível carregar o histórico mais antigo",
|
||||
|
||||
@@ -1551,8 +1551,6 @@ export const ru: TranslationResources = {
|
||||
noFiles: "Файлы и каталоги не найдены",
|
||||
noCommands: "Команды не найдены",
|
||||
failedToLoad: "Не удалось загрузить",
|
||||
chooseProjectForCommands: "Выберите проект, чтобы увидеть команды",
|
||||
chooseModelForCommands: "Выберите модель, чтобы увидеть команды",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Не удалось загрузить старую историю.",
|
||||
|
||||
@@ -1505,8 +1505,6 @@ export const zhCN: TranslationResources = {
|
||||
noFiles: "没有找到文件或目录",
|
||||
noCommands: "没有找到 commands",
|
||||
failedToLoad: "加载失败",
|
||||
chooseProjectForCommands: "选择项目以查看命令",
|
||||
chooseModelForCommands: "选择模型以查看命令",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "无法加载更早历史",
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
ProviderSnapshotEntry,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import { filterSelectableModels } from "./model-catalog";
|
||||
@@ -286,22 +286,19 @@ export function resolveEffectiveComposerThinkingOptionId(
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}
|
||||
|
||||
export function buildDraftCommandTarget(input: {
|
||||
export function buildDraftCommandConfig(input: {
|
||||
selection: ProviderSelectionState;
|
||||
cwd: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}): DraftCommandTarget {
|
||||
}): DraftCommandConfig | undefined {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!cwd) {
|
||||
return { status: "needs-project" };
|
||||
}
|
||||
if (!input.selection.provider) {
|
||||
return { status: "needs-provider" };
|
||||
if (!input.selection.provider || !cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = {
|
||||
return {
|
||||
provider: input.selection.provider,
|
||||
cwd,
|
||||
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
|
||||
@@ -313,7 +310,6 @@ export function buildDraftCommandTarget(input: {
|
||||
: {}),
|
||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
||||
};
|
||||
return { status: "ready", config };
|
||||
}
|
||||
|
||||
export function resolveSubmissionReadiness(input: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { createUserMessage } from "@/types/stream";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
@@ -158,28 +159,17 @@ class FakeDaemonClient {
|
||||
}
|
||||
|
||||
public ownedSubscriptions = true;
|
||||
private daemonVersion: string | null = "0.8.0";
|
||||
|
||||
getLastServerInfoMessage(): ReturnType<DaemonClient["getLastServerInfoMessage"]> {
|
||||
if (this.daemonVersion === null) return null;
|
||||
return {
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
hostname: "test",
|
||||
version: this.daemonVersion,
|
||||
version: "0.8.0",
|
||||
features: { ownedSubscriptions: this.ownedSubscriptions },
|
||||
};
|
||||
}
|
||||
|
||||
// Like the real client, the server info is cleared while disconnected and replaced by the
|
||||
// restarted daemon's handshake before the client reports connected again.
|
||||
daemonRestartsAs(version: string): void {
|
||||
this.daemonVersion = null;
|
||||
this.setConnectionState({ status: "disconnected", reason: "Connection lost" });
|
||||
this.daemonVersion = version;
|
||||
this.setConnectionState({ status: "connected" });
|
||||
}
|
||||
|
||||
subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.state);
|
||||
@@ -2140,37 +2130,6 @@ describe("HostRuntimeStore", () => {
|
||||
expect(useSessionStore.getState().sessions[host.serverId]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows the restarted daemon's version after the same client reconnects", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_restarted",
|
||||
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
|
||||
});
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host: hostProfile }) => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: hostProfile.serverId,
|
||||
hostname: hostProfile.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
store.syncHosts([host]);
|
||||
await waitForHostOnline(store, host.serverId);
|
||||
expect(useSessionStore.getState().sessions[host.serverId]?.serverInfo?.version).toBe("0.8.0");
|
||||
|
||||
fakeClient.daemonRestartsAs("0.9.1");
|
||||
|
||||
expect(store.getSnapshot(host.serverId)?.client).toBe(fakeClient);
|
||||
expect(useSessionStore.getState().sessions[host.serverId]?.serverInfo?.version).toBe("0.9.1");
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("drains snapshot and buffered running transitions exactly once", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_legacy_transitions",
|
||||
@@ -2841,6 +2800,91 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("keeps the next queued message while a sent message replaces the running turn", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_queue_replacement",
|
||||
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
|
||||
});
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
const running = makeFetchAgentsEntry({
|
||||
id: "agent",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
});
|
||||
fakeClient.fetchAgentsResponses.push(
|
||||
makeFetchAgentsPayload({
|
||||
entries: [{ ...running, agent: { ...running.agent, status: "running" } }],
|
||||
}),
|
||||
);
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_queue_replacement",
|
||||
},
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.updateSessionServerInfo(host.serverId, {
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { canonicalSubmittedPrompts: true },
|
||||
});
|
||||
store.syncHosts([host]);
|
||||
await waitForHostOnline(store, host.serverId);
|
||||
await store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} });
|
||||
store.applyAgentTurnLiveness(host.serverId, "agent", {
|
||||
type: "stream_open",
|
||||
turn: { turnId: "turn-1", startedAt: null },
|
||||
});
|
||||
sessionStore.setQueuedMessages(
|
||||
host.serverId,
|
||||
new Map([["agent", [{ id: "second", text: "keep me queued", attachments: [] }]]]),
|
||||
);
|
||||
|
||||
// "Send now" on the first queued message is in flight; the daemon replaces the turn.
|
||||
const sentNow = createUserMessage({
|
||||
clientMessageId: "sent-now",
|
||||
text: "sent now",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
sessionStore.beginAgentMessageSubmission(host.serverId, "agent", sentNow);
|
||||
store.applyAgentTurnLiveness(host.serverId, "agent", {
|
||||
type: "stream_close",
|
||||
turnId: "turn-1",
|
||||
});
|
||||
store.applyAgentTurnLiveness(host.serverId, "agent", {
|
||||
type: "stream_open",
|
||||
turn: { turnId: "turn-2", startedAt: null },
|
||||
});
|
||||
|
||||
expect(fakeClient.sentAgentMessages).toEqual([]);
|
||||
expect(useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get("agent")).toEqual(
|
||||
[{ id: "second", text: "keep me queued", attachments: [] }],
|
||||
);
|
||||
|
||||
// Once the replacement turn ends, the queue drains as usual.
|
||||
sessionStore.acceptAgentMessageSubmission(host.serverId, "agent", "sent-now");
|
||||
sessionStore.setAgentStreamState(host.serverId, "agent", {
|
||||
acknowledgedClientMessageIds: ["sent-now"],
|
||||
});
|
||||
store.applyAgentTurnLiveness(host.serverId, "agent", {
|
||||
type: "stream_close",
|
||||
turnId: "turn-2",
|
||||
});
|
||||
await fakeClient.waitForSentMessages(1);
|
||||
expect(fakeClient.sentAgentMessages.map(([, text]) => text)).toEqual(["keep me queued"]);
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("submits an automatically drained message through the submission producer", async () => {
|
||||
const host = makeHost({ serverId: "srv_drain_submission" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
|
||||
@@ -49,8 +49,8 @@ import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-creden
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import {
|
||||
selectAgentTurnPresentation,
|
||||
useSessionStore,
|
||||
toDaemonServerInfo,
|
||||
type Agent,
|
||||
type WorkspaceDescriptor,
|
||||
type ProjectDescriptor,
|
||||
@@ -2267,13 +2267,6 @@ export class HostRuntimeStore {
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(serverId, snapshot.client, snapshot.clientGeneration);
|
||||
sessionStore.updateSessionClient(serverId, snapshot.client, snapshot.clientGeneration);
|
||||
// A reconnect keeps the same client, so the daemon's handshake (a restart or upgrade can
|
||||
// change its version and features) only reaches the store here. The client clears it while
|
||||
// disconnected; keep the last known value until the next handshake.
|
||||
const serverInfo = snapshot.client.getLastServerInfoMessage();
|
||||
if (serverInfo) {
|
||||
sessionStore.updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo));
|
||||
}
|
||||
}
|
||||
|
||||
private clearHostReplica(serverId: string): void {
|
||||
@@ -2331,6 +2324,9 @@ export class HostRuntimeStore {
|
||||
if (!client || !queue?.length || session.initializingAgents.get(agentId) === true) {
|
||||
return;
|
||||
}
|
||||
// A message sent while the agent runs can replace its turn. The replaced turn closes
|
||||
// before the replacement opens, but the agent is still busy with the sent message.
|
||||
if (selectAgentTurnPresentation(session, agentId).isActive) return;
|
||||
this.queuedAgentDrainInFlight.add(drainKey);
|
||||
const next = queue[0];
|
||||
void sendQueuedComposerMessageNow({
|
||||
|
||||
@@ -2422,7 +2422,7 @@ export function NewWorkspaceScreen({
|
||||
clearDraft={handleClearDraft}
|
||||
autoFocus
|
||||
autoFocusKey={launchFocusKey}
|
||||
commandDraft={composerState?.commandDraft}
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -68,7 +68,7 @@ import type { Theme } from "@/styles/theme";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
|
||||
import { formatCompactTimeAgoAsProse } from "@/utils/time";
|
||||
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
|
||||
@@ -285,19 +285,6 @@ export interface DaemonServerInfo {
|
||||
features?: ServerInfoStatusPayload["features"];
|
||||
}
|
||||
|
||||
export function toDaemonServerInfo(serverInfo: ServerInfoStatusPayload): DaemonServerInfo {
|
||||
return {
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname ?? null,
|
||||
version: serverInfo.version ?? null,
|
||||
...(serverInfo.desktopManaged !== undefined
|
||||
? { desktopManaged: serverInfo.desktopManaged }
|
||||
: {}),
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentTimelineCursorState {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
|
||||
import { UsageBalanceBar } from "./balance-bar";
|
||||
import { usageCopy } from "./copy";
|
||||
import { formatUsageFreshness, type UsageRefresh } from "./model";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
describeCompactTimeAgo,
|
||||
describeTimeAgo,
|
||||
formatCompactTimeAgo,
|
||||
formatCompactTimeAgoAsProse,
|
||||
formatDuration,
|
||||
@@ -14,8 +13,7 @@ describe("formatTimeAgo", () => {
|
||||
|
||||
it.each([
|
||||
["2026-07-16T11:59:55.000Z", "just now"],
|
||||
["2026-07-16T11:59:30.000Z", "just now"],
|
||||
["2026-07-16T11:59:00.000Z", "1m ago"],
|
||||
["2026-07-16T11:59:30.000Z", "30s ago"],
|
||||
["2026-07-16T11:55:00.000Z", "5m ago"],
|
||||
["2026-07-16T10:00:00.000Z", "2h ago"],
|
||||
["2026-07-13T12:00:00.000Z", "3d ago"],
|
||||
@@ -25,20 +23,6 @@ describe("formatTimeAgo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeTimeAgo", () => {
|
||||
const now = new Date("2026-07-16T12:00:00.000Z");
|
||||
|
||||
it.each([
|
||||
["2026-07-16T11:59:30.000Z", "just now", "minute"],
|
||||
["2026-07-16T11:55:00.000Z", "5m ago", "minute"],
|
||||
["2026-07-16T10:00:00.000Z", "2h ago", "hour"],
|
||||
["2026-07-13T12:00:00.000Z", "3d ago", "day"],
|
||||
["2026-01-15T12:00:00.000Z", "Jan 15", "static"],
|
||||
] as const)("formats %s as %s at %s resolution", (date, label, resolution) => {
|
||||
expect(describeTimeAgo(new Date(date), now)).toEqual({ label, resolution });
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeCompactTimeAgo", () => {
|
||||
const now = new Date("2026-07-16T12:00:00.000Z");
|
||||
|
||||
|
||||
@@ -1,10 +1,50 @@
|
||||
/**
|
||||
* How often a relative label can change, which is all a caller needs to know to keep it honest.
|
||||
* How long ago something was, before it's worded. The two formatters below share this so
|
||||
* their thresholds can't drift apart; they differ only in how much room they have to say it.
|
||||
*
|
||||
* `elapsed` takes an "ago" in prose, `now` and `date` read as absolutes and never do.
|
||||
*/
|
||||
type Elapsed =
|
||||
| { kind: "now" }
|
||||
| { kind: "elapsed"; value: string }
|
||||
| { kind: "date"; value: string };
|
||||
|
||||
function describeElapsed(date: Date, now: Date): Elapsed {
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 10) return { kind: "now" };
|
||||
if (diffMin < 1) return { kind: "elapsed", value: `${diffSec}s` };
|
||||
if (diffHour < 1) return { kind: "elapsed", value: `${diffMin}m` };
|
||||
if (diffDay < 1) return { kind: "elapsed", value: `${diffHour}h` };
|
||||
if (diffDay < 7) return { kind: "elapsed", value: `${diffDay}d` };
|
||||
|
||||
// For older dates, show abbreviated month and day
|
||||
const month = date.toLocaleDateString("en-US", { month: "short" });
|
||||
return { kind: "date", value: `${month} ${date.getDate()}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as a human-friendly relative time string
|
||||
* Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15"
|
||||
*/
|
||||
export function formatTimeAgo(date: Date, now: Date = new Date()): string {
|
||||
const elapsed = describeElapsed(date, now);
|
||||
if (elapsed.kind === "now") return "just now";
|
||||
if (elapsed.kind === "elapsed") return `${elapsed.value} ago`;
|
||||
return elapsed.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* How often a compact label can change, which is all a caller needs to know to keep it honest.
|
||||
* `static` means it never will again.
|
||||
*/
|
||||
export type RelativeTimeResolution = "minute" | "hour" | "day" | "static";
|
||||
|
||||
export interface RelativeTimeLabel {
|
||||
export interface CompactTimeAgo {
|
||||
label: string;
|
||||
resolution: RelativeTimeResolution;
|
||||
}
|
||||
@@ -16,71 +56,35 @@ const DAY_MS = 24 * HOUR_MS;
|
||||
const ABSOLUTE_AFTER_MS = 7 * DAY_MS;
|
||||
|
||||
/**
|
||||
* How long ago something was, before it's worded. The prose and compact formatters share this so
|
||||
* their thresholds can't drift apart; they differ only in how much room they have to say it.
|
||||
*
|
||||
* `elapsed` takes an "ago" in prose, `now` and `date` read as absolutes and never do.
|
||||
* The same instant with the prose removed, for somewhere with no room for it — a dense list
|
||||
* where the column is understood to be a timestamp and "ago" is the only word on the line.
|
||||
* Examples: "now", "5m", "2h", "3d", "Jan 15".
|
||||
*
|
||||
* Deliberately never sub-minute. A seconds label is only correct for the second it was rendered,
|
||||
* so it either lies or forces a once-a-second re-render of a list that has nothing new to say.
|
||||
* Everything under a minute is "now", which is both true and stable.
|
||||
*
|
||||
* The resolution comes back with the value so a caller can wake at the rate the label actually
|
||||
* changes instead of guessing — see `useTimeAgo`.
|
||||
* The resolution comes back with the label so a caller can wake at the rate the label actually
|
||||
* changes instead of guessing — see `useCompactTimeAgo`.
|
||||
*/
|
||||
type Age =
|
||||
| { kind: "now"; resolution: "minute" }
|
||||
| { kind: "elapsed"; value: string; resolution: Exclude<RelativeTimeResolution, "static"> }
|
||||
| { kind: "date"; value: string; resolution: "static" };
|
||||
|
||||
function describeAge(date: Date, now: Date): Age {
|
||||
export function describeCompactTimeAgo(date: Date, now: Date = new Date()): CompactTimeAgo {
|
||||
const elapsedMs = now.getTime() - date.getTime();
|
||||
|
||||
if (elapsedMs < MINUTE_MS) {
|
||||
return { kind: "now", resolution: "minute" };
|
||||
return { label: "now", resolution: "minute" };
|
||||
}
|
||||
if (elapsedMs < HOUR_MS) {
|
||||
return {
|
||||
kind: "elapsed",
|
||||
value: `${Math.floor(elapsedMs / MINUTE_MS)}m`,
|
||||
resolution: "minute",
|
||||
};
|
||||
return { label: `${Math.floor(elapsedMs / MINUTE_MS)}m`, resolution: "minute" };
|
||||
}
|
||||
if (elapsedMs < DAY_MS) {
|
||||
return { kind: "elapsed", value: `${Math.floor(elapsedMs / HOUR_MS)}h`, resolution: "hour" };
|
||||
return { label: `${Math.floor(elapsedMs / HOUR_MS)}h`, resolution: "hour" };
|
||||
}
|
||||
if (elapsedMs < ABSOLUTE_AFTER_MS) {
|
||||
return { kind: "elapsed", value: `${Math.floor(elapsedMs / DAY_MS)}d`, resolution: "day" };
|
||||
return { label: `${Math.floor(elapsedMs / DAY_MS)}d`, resolution: "day" };
|
||||
}
|
||||
|
||||
const month = date.toLocaleDateString("en-US", { month: "short" });
|
||||
return { kind: "date", value: `${month} ${date.getDate()}`, resolution: "static" };
|
||||
}
|
||||
|
||||
/**
|
||||
* A human-friendly relative time, with the resolution it changes at.
|
||||
* Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15".
|
||||
*/
|
||||
export function describeTimeAgo(date: Date, now: Date = new Date()): RelativeTimeLabel {
|
||||
const age = describeAge(date, now);
|
||||
if (age.kind === "now") return { label: "just now", resolution: age.resolution };
|
||||
if (age.kind === "elapsed") return { label: `${age.value} ago`, resolution: age.resolution };
|
||||
return { label: age.value, resolution: age.resolution };
|
||||
}
|
||||
|
||||
export function formatTimeAgo(date: Date, now: Date = new Date()): string {
|
||||
return describeTimeAgo(date, now).label;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same instant with the prose removed, for somewhere with no room for it — a dense list
|
||||
* where the column is understood to be a timestamp and "ago" is the only word on the line.
|
||||
* Examples: "now", "5m", "2h", "3d", "Jan 15".
|
||||
*/
|
||||
export function describeCompactTimeAgo(date: Date, now: Date = new Date()): RelativeTimeLabel {
|
||||
const age = describeAge(date, now);
|
||||
if (age.kind === "now") return { label: "now", resolution: age.resolution };
|
||||
return { label: age.value, resolution: age.resolution };
|
||||
return { label: `${month} ${date.getDate()}`, resolution: "static" };
|
||||
}
|
||||
|
||||
export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
collectAllPanes,
|
||||
DEFAULT_PANE_ID,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
useWorkspaceLayoutStore,
|
||||
type WorkspaceTabPlacement,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
@@ -110,10 +109,6 @@ export function openWorkspaceTargetAtLocation(
|
||||
if (shouldOpenBeside && !targetAlreadyExists) {
|
||||
const paneId = store.ensureSidePane(input.workspaceKey);
|
||||
placement = paneId ? { mode: "prefer", paneId } : undefined;
|
||||
} else if (!shouldOpenBeside && layout && input.parentTabId) {
|
||||
// A target opened from a tab belongs in that tab's pane, whichever pane has focus.
|
||||
const parentPane = findPaneContainingTab(layout.root, input.parentTabId);
|
||||
placement = parentPane ? { mode: "prefer", paneId: parentPane.id } : undefined;
|
||||
}
|
||||
return store.openTab({
|
||||
workspaceKey: input.workspaceKey,
|
||||
|
||||
@@ -136,24 +136,6 @@ describe("resolveWorkspaceFilePaths", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves files under a drive-root workspace without doubling the separator", () => {
|
||||
expect(resolveWorkspaceFilePaths({ path: "C:/src/app.ts", workspaceRoot: "C:\\" })).toEqual({
|
||||
absolutePath: "C:/src/app.ts",
|
||||
relativePath: "src/app.ts",
|
||||
});
|
||||
expect(resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "C:\\" })).toEqual({
|
||||
absolutePath: "C:/src/app.ts",
|
||||
relativePath: "src/app.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves files under the POSIX root workspace", () => {
|
||||
expect(resolveWorkspaceFilePaths({ path: "/src/app.ts", workspaceRoot: "/" })).toEqual({
|
||||
absolutePath: "/src/app.ts",
|
||||
relativePath: "src/app.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for home-relative paths that cannot be anchored", () => {
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }),
|
||||
|
||||
@@ -129,17 +129,6 @@ function startsWithPath(value: string, prefix: string): boolean {
|
||||
: value.startsWith(prefix);
|
||||
}
|
||||
|
||||
function pathWithinRoot(root: string, relativePath: string): string {
|
||||
return root.endsWith("/") ? `${root}${relativePath}` : `${root}/${relativePath}`;
|
||||
}
|
||||
|
||||
/** Whether an absolute path names the workspace directory itself. */
|
||||
export function isWorkspaceRootPath(input: { path: string; workspaceRoot: string }): boolean {
|
||||
const path = normalizeAbsolutePath(input.path.trim().replace(/\\/g, "/"));
|
||||
const root = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
|
||||
return path !== null && root !== null && pathsEqual(path, root);
|
||||
}
|
||||
|
||||
export interface ResolvedWorkspaceFilePaths {
|
||||
/** Absolute path on the host, suitable for opening in an editor / file manager. */
|
||||
absolutePath: string;
|
||||
@@ -170,7 +159,7 @@ export function resolveWorkspaceFilePaths(input: {
|
||||
if (pathsEqual(normalizedFile, workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
const prefix = pathWithinRoot(workspaceRoot, "");
|
||||
const prefix = `${workspaceRoot}/`;
|
||||
const relativePath = startsWithPath(normalizedFile, prefix)
|
||||
? normalizedFile.slice(prefix.length)
|
||||
: null;
|
||||
@@ -185,5 +174,5 @@ export function resolveWorkspaceFilePaths(input: {
|
||||
if (!relativePath) {
|
||||
return null;
|
||||
}
|
||||
return { absolutePath: pathWithinRoot(workspaceRoot, relativePath), relativePath };
|
||||
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
|
||||
}
|
||||
|
||||
@@ -82,15 +82,10 @@ const EXPECTED_CLAUDE_MODELS = [
|
||||
model: "Opus 4.8",
|
||||
descriptionFragment: "Previous release",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-5-5",
|
||||
model: "Sonnet 5.5",
|
||||
descriptionFragment: "Best for everyday tasks",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-5",
|
||||
model: "Sonnet 5",
|
||||
descriptionFragment: "Previous release",
|
||||
descriptionFragment: "Best for everyday tasks",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-7[1m]",
|
||||
@@ -435,7 +430,7 @@ try {
|
||||
"--quiet should print the same ordered model IDs returned by --json",
|
||||
);
|
||||
assert(
|
||||
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5-5"),
|
||||
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"),
|
||||
"captured --json output should include the current Claude everyday model id",
|
||||
);
|
||||
console.log("✓ provider models --quiet outputs model IDs only\n");
|
||||
|
||||
@@ -58,23 +58,10 @@ export async function runFollowUp(url: string, agentId: string): Promise<string
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.agents.ref(agentId).run("Summarize your progress and next step.", {
|
||||
activeTurnBehavior: "interrupt",
|
||||
});
|
||||
const result = await client.agents.ref(agentId).run("Summarize your progress and next step.");
|
||||
if (result.status !== "idle") throw new Error(result.error ?? result.status);
|
||||
return result.lastMessage;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function steerAgent(url: string, agentId: string, prompt: string): Promise<void> {
|
||||
const client = createClient(url);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.agents.ref(agentId).send(prompt, { activeTurnBehavior: "steer" });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6906,70 +6906,6 @@ test("creation reconnect observation uses connection-owned subscriptions and rel
|
||||
expect(phases).toEqual(["accepted", "failed"]);
|
||||
});
|
||||
|
||||
test("uploadFile started while connecting waits for the connection and uploads", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "upload-while-connecting",
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
suppressSendErrors: true,
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const connection = client.connect();
|
||||
expect(client.getConnectionState().status).toBe("connecting");
|
||||
|
||||
const upload = client.uploadFile({
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
bytes: new TextEncoder().encode("hello world"),
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
requestId: "req-upload-connecting",
|
||||
chunkSize: 5,
|
||||
});
|
||||
void upload.catch(() => undefined);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
mock.triggerOpen();
|
||||
await connection;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const frames = mock.sent
|
||||
.filter((frame) => typeof frame !== "string")
|
||||
.map(assertUint8Array)
|
||||
.map(decodeFileTransferFrame);
|
||||
expect(frames.map((frame) => frame.opcode)).toEqual([
|
||||
FileTransferOpcode.FileBegin,
|
||||
FileTransferOpcode.FileChunk,
|
||||
FileTransferOpcode.FileChunk,
|
||||
FileTransferOpcode.FileChunk,
|
||||
FileTransferOpcode.FileEnd,
|
||||
]);
|
||||
});
|
||||
const requestIndex = mock.sent.findIndex(
|
||||
(frame) =>
|
||||
typeof frame === "string" && parseSentFrame(frame).requestId === "req-upload-connecting",
|
||||
);
|
||||
expect(parseSentFrame(mock.sent[requestIndex])).toMatchObject({ type: "file.upload.request" });
|
||||
expect(requestIndex).toBeLessThan(mock.sent.findIndex((frame) => typeof frame !== "string"));
|
||||
|
||||
const file = {
|
||||
type: "uploaded_file" as const,
|
||||
id: "upload_req-upload-connecting",
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
path: "/tmp/paseo-uploads/upload_req-upload-connecting/notes.txt",
|
||||
};
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "file.upload.response",
|
||||
payload: { requestId: "req-upload-connecting", file, error: null },
|
||||
}),
|
||||
);
|
||||
await expect(upload).resolves.toEqual({ requestId: "req-upload-connecting", file, error: null });
|
||||
});
|
||||
|
||||
test("uploadFile stops sending chunks when the connection closes between sends", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
|
||||
@@ -425,7 +425,6 @@ export interface DaemonClientTrace {
|
||||
|
||||
export interface SendMessageOptions {
|
||||
messageId?: string;
|
||||
/** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */
|
||||
activeTurnBehavior?: ActiveTurnBehavior;
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
attachments?: SendAgentMessageRequest["attachments"];
|
||||
@@ -1170,7 +1169,7 @@ function toReasonCode(reason: string | null | undefined): string | null {
|
||||
}
|
||||
|
||||
interface PendingSend {
|
||||
send: () => void;
|
||||
message: SessionInboundMessage;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle: ReturnType<typeof setTimeout>;
|
||||
@@ -1798,23 +1797,12 @@ export class DaemonClient {
|
||||
* This prevents waiters from hanging forever when called during connection.
|
||||
*/
|
||||
private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise<void> {
|
||||
return this.sendWhenConnected(() => {
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves once connected, waiting out a connection that is still being established. */
|
||||
private whenConnected(): Promise<void> {
|
||||
return this.sendWhenConnected(() => undefined);
|
||||
}
|
||||
|
||||
private sendWhenConnected(send: () => void): Promise<void> {
|
||||
const status = this.connectionState.status;
|
||||
|
||||
// If connected, send immediately
|
||||
if (this.transport && status === "connected") {
|
||||
send();
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -1835,7 +1823,7 @@ export class DaemonClient {
|
||||
);
|
||||
}, DEFAULT_SEND_QUEUE_TIMEOUT_MS);
|
||||
|
||||
this.pendingSendQueue.push({ send, resolve, reject, timeoutHandle });
|
||||
this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1854,7 +1842,8 @@ export class DaemonClient {
|
||||
clearTimeout(pending.timeoutHandle);
|
||||
try {
|
||||
if (this.transport && this.connectionState.status === "connected") {
|
||||
pending.send();
|
||||
const payload = SessionInboundMessageSchema.parse(pending.message);
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
pending.resolve();
|
||||
} else {
|
||||
pending.reject(new DaemonConnectionError("Connection lost before message could be sent"));
|
||||
@@ -4857,8 +4846,6 @@ export class DaemonClient {
|
||||
if (!bytes) {
|
||||
throw new Error("File bytes are required.");
|
||||
}
|
||||
// The file frames bypass the send queue, so start only on an open connection.
|
||||
await this.whenConnected();
|
||||
const uploadTransport = this.transport;
|
||||
const resolvedRequestId = this.createRequestId(input.requestId);
|
||||
const modifiedAt = input.modifiedAt ?? new Date().toISOString();
|
||||
|
||||
@@ -744,17 +744,13 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
|
||||
expect(updatedAgents).toEqual(["Updated"]);
|
||||
expect(agent.current()).toEqual(updatedAgent);
|
||||
|
||||
const sendPromise = agent.send("hello", {
|
||||
messageId: "message-sdk",
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
const sendPromise = agent.send("hello", { messageId: "message-sdk" });
|
||||
const sendRequest = parseSentSessionMessage(ws.sent.at(-1));
|
||||
expect(sendRequest).toMatchObject({
|
||||
type: "send_agent_message_request",
|
||||
agentId: "agent_sdk",
|
||||
text: "hello",
|
||||
messageId: "message-sdk",
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
|
||||
ws.message(
|
||||
@@ -772,7 +768,6 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
|
||||
|
||||
const runPromise = agent.run("finish the task", {
|
||||
messageId: "run-message-sdk",
|
||||
activeTurnBehavior: "interrupt",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const runSendRequest = parseSentSessionMessage(ws.sent.at(-1));
|
||||
@@ -781,7 +776,6 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
|
||||
agentId: "agent_sdk",
|
||||
text: "finish the task",
|
||||
messageId: "run-message-sdk",
|
||||
activeTurnBehavior: "interrupt",
|
||||
});
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
ProjectPlacementPayload,
|
||||
WorkspaceProjectDescriptorPayload,
|
||||
RefreshProvidersSnapshotResponseMessage,
|
||||
SendAgentMessageRequest,
|
||||
SessionOutboundMessage,
|
||||
WorkspaceDescriptorPayload,
|
||||
WorkspaceCreateRequest,
|
||||
@@ -54,7 +55,6 @@ import type {
|
||||
FetchAgentTimelineDirection,
|
||||
FetchAgentTimelinePayload,
|
||||
FetchAgentTimelineProjection,
|
||||
SendMessageOptions,
|
||||
WaitForFinishResult,
|
||||
} from "./daemon-client.js";
|
||||
|
||||
@@ -276,7 +276,11 @@ export interface PaseoAgentTimelineRefetchOptions {
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export type PaseoAgentSendOptions = SendMessageOptions;
|
||||
export interface PaseoAgentSendOptions {
|
||||
messageId?: string;
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
attachments?: SendAgentMessageRequest["attachments"];
|
||||
}
|
||||
|
||||
export interface PaseoAgentRunOptions extends PaseoAgentSendOptions {
|
||||
timeoutMs?: number;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const skill = readFileSync(path.join(repository, "skills/paseo-plugin/SKILL.md"), "utf8");
|
||||
|
||||
function sectionCode(heading: string): string {
|
||||
const section = skill.slice(skill.indexOf(`\n## ${heading}\n`));
|
||||
const code = /```tsx\n([\s\S]*?)\n```/.exec(section)?.[1];
|
||||
if (!code) throw new Error(`No tsx example under "${heading}"`);
|
||||
return code;
|
||||
}
|
||||
|
||||
// Checks the example as a plugin client file, with the same options as the plugin examples.
|
||||
function typeErrors(code: string): string[] {
|
||||
const configPath = path.join(repository, "packages/plugin/tsconfig.examples.json");
|
||||
const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
|
||||
const { options } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath));
|
||||
const file = path.join(repository, "plugin-examples/skill-example/client/example.tsx");
|
||||
const host = ts.createCompilerHost(options);
|
||||
const getSourceFile = host.getSourceFile;
|
||||
host.getSourceFile = (name, language, ...rest) =>
|
||||
path.resolve(name) === file
|
||||
? ts.createSourceFile(name, code, language)
|
||||
: getSourceFile.call(host, name, language, ...rest);
|
||||
host.fileExists = (name) => path.resolve(name) === file || ts.sys.fileExists(name);
|
||||
const program = ts.createProgram([file], options, host);
|
||||
return ts
|
||||
.getPreEmitDiagnostics(program)
|
||||
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
|
||||
}
|
||||
|
||||
describe("paseo-plugin skill examples", () => {
|
||||
it("composer pill example typechecks against the plugin SDK", () => {
|
||||
expect(typeErrors(sectionCode("Add a composer pill"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -4851,53 +4851,6 @@ test("persists live mode, model, and thinking changes without an external snapsh
|
||||
expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4");
|
||||
});
|
||||
|
||||
test("model changes persist the resolved thinking and fresh provider handle before another turn", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-model-selection-"));
|
||||
class ModelSession extends TestAgentSession {
|
||||
selection = { model: "source", thinkingOptionId: "medium" as string | undefined };
|
||||
async setModel(model: string | null) {
|
||||
this.selection = { model: model ?? "default", thinkingOptionId: undefined };
|
||||
this.pushEvent({
|
||||
type: "thinking_option_changed",
|
||||
provider: "codex",
|
||||
thinkingOptionId: null,
|
||||
});
|
||||
}
|
||||
describePersistence() {
|
||||
return { provider: "codex" as const, sessionId: this.id, metadata: { ...this.selection } };
|
||||
}
|
||||
}
|
||||
class ModelClient extends TestAgentClient {
|
||||
async createSession(config: AgentSessionConfig) {
|
||||
return new ModelSession(config);
|
||||
}
|
||||
}
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new ModelClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, model: "source", thinkingOptionId: "medium" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
await manager.setAgentModel(agent.id, "target");
|
||||
await manager.flush();
|
||||
const stored = await storage.get(agent.id);
|
||||
expect(stored?.config?.model).toBe("target");
|
||||
expect(stored?.config?.thinkingOptionId).toBeUndefined();
|
||||
expect(stored?.persistence?.metadata?.model).toBe("target");
|
||||
expect(stored?.persistence?.metadata?.thinkingOptionId).toBeUndefined();
|
||||
} finally {
|
||||
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("later explicit config mutations win over events emitted by earlier mutations", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-config-mutation-order-"));
|
||||
class ConfigMutationSession extends TestAgentSession {
|
||||
|
||||
@@ -1943,7 +1943,6 @@ export class AgentManager {
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
|
||||
}
|
||||
this.refreshSessionPersistence(agent);
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
@@ -449,63 +449,6 @@ describe("PluginAgentClientRegistry", () => {
|
||||
expect(eventsOfType(events, "turn_failed")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("leaves a completed session without a failure when its plugin provider is removed", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: "/workspace",
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.run("hello", { clientMessageId: "completed-message" });
|
||||
expect(eventsOfType(events, "turn_completed")).toHaveLength(1);
|
||||
|
||||
registry.replace([]);
|
||||
await harness.waitForClose();
|
||||
|
||||
expect(eventsOfType(events, "turn_failed")).toEqual([]);
|
||||
await expect(
|
||||
session.startTurn("after reload", { clientMessageId: "after-reload" }),
|
||||
).rejects.toBeInstanceOf(StaleProviderSessionError);
|
||||
});
|
||||
|
||||
test("fails an accepted turn that has not started when its plugin provider is removed", async () => {
|
||||
const harness = createProviderHarness({
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.prompt") return false;
|
||||
emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "turn", turnId: "accepted-turn" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: "/workspace",
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await expect(
|
||||
session.startTurn("hello", { clientMessageId: "accepted-message" }),
|
||||
).resolves.toEqual({ turnId: "accepted-turn" });
|
||||
|
||||
registry.replace([]);
|
||||
await harness.waitForClose();
|
||||
|
||||
expect(eventsOfType(events, "turn_failed")).toEqual([
|
||||
expect.objectContaining({ error: "Provider connection closed" }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("closes a stale session after its plugin provider is replaced", async () => {
|
||||
const old = createProviderHarness();
|
||||
const next = createProviderHarness();
|
||||
|
||||
@@ -547,7 +547,6 @@ class ProviderRuntimeSession {
|
||||
string,
|
||||
Deferred<Extract<ProviderEvent, { type: "session.prompt_result" }>>
|
||||
>();
|
||||
private readonly activeTurnIds = new Set<string>();
|
||||
private terminal = false;
|
||||
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
|
||||
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
|
||||
@@ -703,7 +702,6 @@ class ProviderRuntimeSession {
|
||||
return;
|
||||
}
|
||||
if (event.type === "session.prompt_result") {
|
||||
if (event.result.type === "turn") this.activeTurnIds.add(event.result.turnId);
|
||||
this.prompts.get(event.clientMessageId)?.resolve(event);
|
||||
return;
|
||||
}
|
||||
@@ -722,16 +720,14 @@ class ProviderRuntimeSession {
|
||||
}
|
||||
|
||||
connectionClosed(error = new Error("Provider connection closed")): void {
|
||||
// Closing fails only an interrupted turn. An idle session goes stale, and its next prompt
|
||||
// reopens it from persistence.
|
||||
if (!this.terminal && this.activeTurnIds.size > 0) {
|
||||
if (!this.terminal) {
|
||||
this.terminal = true;
|
||||
this.publish({
|
||||
type: "session.runtime_failed",
|
||||
sessionId: this.id,
|
||||
error: { message: error.message },
|
||||
});
|
||||
}
|
||||
this.terminal = true;
|
||||
this.rejectPending(error);
|
||||
}
|
||||
|
||||
@@ -755,10 +751,6 @@ class ProviderRuntimeSession {
|
||||
}
|
||||
|
||||
private publish(event: ProviderEvent): void {
|
||||
if (event.type === "session.turn") {
|
||||
if (event.state === "started") this.activeTurnIds.add(event.turnId);
|
||||
else this.activeTurnIds.delete(event.turnId);
|
||||
}
|
||||
if (event.type === "session.config") this.config = event.config;
|
||||
if (event.type === "session.commands") this.commands = [...event.commands];
|
||||
if (event.type === "session.persistence" && this.restoration === "core") {
|
||||
|
||||
@@ -60,7 +60,6 @@ const mockState = vi.hoisted(() => {
|
||||
isCommandAvailable: vi.fn(async (_command: string) => false),
|
||||
runtimeModels: new Map<string, AgentModelDefinition[]>(),
|
||||
cursorListFeaturesConfigs: [] as AgentSessionConfig[],
|
||||
codexNativeArchiveCalls: [] as Array<{ state: "archive" | "restore"; handle: unknown }>,
|
||||
reset() {
|
||||
this.constructorArgs.claude = [];
|
||||
this.constructorArgs.codex = [];
|
||||
@@ -74,7 +73,6 @@ const mockState = vi.hoisted(() => {
|
||||
this.isCommandAvailable.mockImplementation(async (_command: string) => false);
|
||||
this.runtimeModels.clear();
|
||||
this.cursorListFeaturesConfigs = [];
|
||||
this.codexNativeArchiveCalls = [];
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -173,14 +171,6 @@ vi.mock("./providers/codex-app-server-agent.js", () => ({
|
||||
};
|
||||
}
|
||||
|
||||
async archiveNativeSession(handle: unknown): Promise<void> {
|
||||
mockState.codexNativeArchiveCalls.push({ state: "archive", handle });
|
||||
}
|
||||
|
||||
async unarchiveNativeSession(handle: unknown): Promise<void> {
|
||||
mockState.codexNativeArchiveCalls.push({ state: "restore", handle });
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const command: { mode?: string; argv?: string[] } | undefined =
|
||||
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
|
||||
@@ -661,22 +651,6 @@ test("new provider extending claude appears in registry", () => {
|
||||
expect(registry.zai.createClient(logger).provider).toBe("zai");
|
||||
});
|
||||
|
||||
test("new provider extending codex archives and unarchives its native sessions", async () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: { "my-codex": { extends: "codex", label: "My Codex" } },
|
||||
});
|
||||
const client = registry["my-codex"].createClient(logger);
|
||||
const handle = { provider: "my-codex", sessionId: "thread-1", nativeHandle: "thread-1" };
|
||||
|
||||
await client.archiveNativeSession?.(handle);
|
||||
await client.unarchiveNativeSession?.(handle);
|
||||
|
||||
expect(mockState.codexNativeArchiveCalls).toEqual([
|
||||
{ state: "archive", handle: { ...handle, provider: "codex" } },
|
||||
{ state: "restore", handle: { ...handle, provider: "codex" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("built-in OMP override keeps the real OMP adapter enabled and launchable", async () => {
|
||||
const omp = new FakeOmp(["custom-omp"]);
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
|
||||
@@ -488,8 +488,6 @@ function wrapClientProvider(
|
||||
const listImportableSessions = inner.listImportableSessions?.bind(inner);
|
||||
const importSession = inner.importSession?.bind(inner);
|
||||
const listFeatures = inner.listFeatures?.bind(inner);
|
||||
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
|
||||
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
|
||||
|
||||
return {
|
||||
provider,
|
||||
@@ -578,12 +576,6 @@ function wrapClientProvider(
|
||||
};
|
||||
}
|
||||
: undefined,
|
||||
archiveNativeSession: archiveNativeSession
|
||||
? async (handle) => await archiveNativeSession({ ...handle, provider: inner.provider })
|
||||
: undefined,
|
||||
unarchiveNativeSession: unarchiveNativeSession
|
||||
? async (handle) => await unarchiveNativeSession({ ...handle, provider: inner.provider })
|
||||
: undefined,
|
||||
getCatalogCacheKey: inner.getCatalogCacheKey?.bind(inner),
|
||||
isAvailable: (signal, options) => inner.isAvailable(signal, options),
|
||||
getDiagnostic: inner.getDiagnostic?.bind(inner),
|
||||
|
||||
@@ -497,7 +497,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
resolveVersion: async () => "2.1.284",
|
||||
resolveVersion: async () => "2.1.219",
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
|
||||
});
|
||||
const { models } = await client.fetchCatalog({
|
||||
@@ -516,8 +516,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh");
|
||||
expect(getThinkingIds("claude-sonnet-5")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-sonnet-5-5")).toContain("xhigh");
|
||||
expect(getThinkingIds("claude-sonnet-5-5")).not.toContain("off");
|
||||
expect(getThinkingIds("claude-opus-4-7[1m]")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-opus-4-7")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode");
|
||||
@@ -1291,26 +1289,6 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rejects disabled thinking on Sonnet 5.5, which only runs with adaptive thinking", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-sonnet-5-5",
|
||||
});
|
||||
|
||||
await expect(session.setThinkingOption?.("off")).rejects.toThrow(
|
||||
"Thinking option 'off' is not available for model 'claude-sonnet-5-5'",
|
||||
);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rejects an initial disabled-thinking config for an unsupported model", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
|
||||
@@ -93,19 +93,10 @@ export const CLAUDE_MODEL_MANIFEST = [
|
||||
supportsThinkingDisabled: true,
|
||||
supportsFastMode: true,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-5-5",
|
||||
label: "Sonnet 5.5",
|
||||
description: "Sonnet 5.5 · Best for everyday tasks",
|
||||
minimumClaudeCodeVersion: "2.1.284",
|
||||
defaultThinkingOptionId: "medium",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-5",
|
||||
label: "Sonnet 5",
|
||||
description: "Sonnet 5 · Previous release",
|
||||
description: "Sonnet 5 · Best for everyday tasks",
|
||||
contextWindowMaxTokens: 200_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
supportsThinkingDisabled: true,
|
||||
|
||||
@@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s
|
||||
return configDir;
|
||||
}
|
||||
|
||||
function createCatalogClient(claudeCodeVersion = "2.1.284"): ClaudeAgentClient {
|
||||
function createCatalogClient(claudeCodeVersion = "2.1.280"): ClaudeAgentClient {
|
||||
return new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveVersion: async () => claudeCodeVersion,
|
||||
@@ -57,7 +57,6 @@ describe("getClaudeModels", () => {
|
||||
"claude-fable-5[1m]",
|
||||
"claude-opus-4-8[1m]",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-5-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-5[1m]",
|
||||
"claude-opus-4-7[1m]",
|
||||
@@ -91,7 +90,6 @@ describe("getClaudeModels", () => {
|
||||
["claude-fable-5[1m]", 1_000_000],
|
||||
["claude-opus-4-8[1m]", 1_000_000],
|
||||
["claude-opus-4-8", 200_000],
|
||||
["claude-sonnet-5-5", 1_000_000],
|
||||
["claude-sonnet-5", 200_000],
|
||||
["claude-sonnet-5[1m]", 1_000_000],
|
||||
["claude-opus-4-7[1m]", 1_000_000],
|
||||
@@ -118,9 +116,6 @@ describe("getClaudeModels", () => {
|
||||
expect(getClaudeModels("2.1.279").find((model) => model.isDefault)?.id).toBe("claude-opus-5");
|
||||
expect(getClaudeModels("2.1.280").map((model) => model.id)).toContain("claude-opus-5-5");
|
||||
expect(getClaudeModels("2.1.280").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
|
||||
|
||||
expect(getClaudeModels("2.1.283").map((model) => model.id)).not.toContain("claude-sonnet-5-5");
|
||||
expect(getClaudeModels("2.1.284").map((model) => model.id)).toContain("claude-sonnet-5-5");
|
||||
});
|
||||
|
||||
it("derives thinking options from model effort capabilities", () => {
|
||||
@@ -183,8 +178,6 @@ describe("getClaudeModels", () => {
|
||||
it.each([
|
||||
["claude-opus-5-5", false, "medium"],
|
||||
["claude-opus-5-5-20260401", false, "medium"],
|
||||
["claude-sonnet-5-5", false, "medium"],
|
||||
["claude-sonnet-5-5-20260928", false, "medium"],
|
||||
["claude-opus-5", true, "high"],
|
||||
["claude-opus-5-20260724", true, "high"],
|
||||
["claude-sonnet-5", true, "high"],
|
||||
@@ -474,16 +467,6 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
||||
"claude-opus-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not collapse a prefixed Sonnet 5.5 onto Sonnet 5", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("anthropic/claude-sonnet-5-5")).toBe("claude-sonnet-5-5");
|
||||
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-sonnet-5-5-20260928-v1:0")).toBe(
|
||||
"claude-sonnet-5-5",
|
||||
);
|
||||
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-sonnet-5-20260101-v1:0")).toBe(
|
||||
"claude-sonnet-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClaudeCodeVersion", () => {
|
||||
@@ -617,42 +600,6 @@ describe("Claude Opus 5.5 catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Sonnet 5.5 catalog", () => {
|
||||
it("offers one Sonnet 5.5 entry with a 1M context window", () => {
|
||||
const sonnet55Models = getClaudeModels()
|
||||
.filter((model) => model.id.startsWith("claude-sonnet-5-5"))
|
||||
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
|
||||
|
||||
expect(sonnet55Models).toEqual([
|
||||
{ id: "claude-sonnet-5-5", label: "Sonnet 5.5", contextWindowMaxTokens: 1_000_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers every effort level except off, because Sonnet 5.5 cannot disable thinking", () => {
|
||||
const sonnet55 = getClaudeModels().find((model) => model.id === "claude-sonnet-5-5");
|
||||
|
||||
expect(sonnet55?.thinkingOptions?.map((option) => option.id)).toEqual([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
|
||||
]);
|
||||
expect(sonnet55?.defaultThinkingOptionId).toBe("medium");
|
||||
expect(
|
||||
sonnet55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
|
||||
).toEqual(["medium"]);
|
||||
});
|
||||
|
||||
it("resolves suffixed and dated Sonnet 5.5 IDs to the single catalog entry", () => {
|
||||
expect(findClaudeModel("claude-sonnet-5-5[1m]")?.id).toBe("claude-sonnet-5-5");
|
||||
expect(findClaudeModel("claude-sonnet-5-5-20260928")?.id).toBe("claude-sonnet-5-5");
|
||||
expect(findClaudeModel("claude-sonnet-5-5-20260928[1m]")?.id).toBe("claude-sonnet-5-5");
|
||||
expect(findClaudeModel("claude-sonnet-5-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claudeManifestModelSupportsFastMode", () => {
|
||||
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
||||
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
||||
@@ -663,7 +610,6 @@ describe("claudeManifestModelSupportsFastMode", () => {
|
||||
it("supports fast mode on Opus 5 but not on other Claude 5 models", () => {
|
||||
expect(claudeManifestModelSupportsFastMode("claude-opus-5-5")).toBe(true);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5-5")).toBe(false);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false);
|
||||
|
||||
@@ -1117,42 +1117,6 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("starts a session without a model on the project's Codex model and effort", async () => {
|
||||
const cwd = "/workspace/project";
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"config/read": (params) => ({
|
||||
config:
|
||||
(params as { cwd?: unknown }).cwd === cwd
|
||||
? { model: "project-model", model_reasoning_effort: "high" }
|
||||
: { model: "global-model", model_reasoning_effort: "low" },
|
||||
}),
|
||||
getUserSavedConfig: () => ({
|
||||
config: { model: "global-model", modelReasoningEffort: "low" },
|
||||
}),
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd, model: undefined }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
|
||||
try {
|
||||
await session.startTurn("use project defaults");
|
||||
|
||||
await expect(appServer.waitForRequest("thread/start")).resolves.toMatchObject({
|
||||
model: "project-model",
|
||||
});
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
model: "project-model",
|
||||
effort: "high",
|
||||
});
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves cwd-resolved Codex writable roots under an explicit workflow mode", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"config/read": () => ({
|
||||
|
||||
@@ -3100,32 +3100,10 @@ const CodexNotificationSchema = z.union([
|
||||
),
|
||||
]);
|
||||
|
||||
// config/read resolves the effective layered config, including a trusted project's
|
||||
// .codex/config.toml when given its cwd; getUserSavedConfig predates it.
|
||||
async function readCodexConfiguredDefaults(
|
||||
client: CodexAppServerClient,
|
||||
logger: Logger,
|
||||
cwd: string | null,
|
||||
): Promise<CodexConfiguredDefaults> {
|
||||
let configReadDefaults: CodexConfiguredDefaults = {};
|
||||
try {
|
||||
const response = toObjectRecord(await client.request("config/read", { cwd }));
|
||||
const config = toObjectRecord(response?.config);
|
||||
const modelValue = typeof config?.model === "string" ? config.model : undefined;
|
||||
const thinkingOptionValue =
|
||||
typeof config?.model_reasoning_effort === "string" ? config.model_reasoning_effort : null;
|
||||
configReadDefaults = {
|
||||
model: normalizeCodexModelId(modelValue),
|
||||
thinkingOptionId: normalizeCodexThinkingOptionId(thinkingOptionValue),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.debug({ error }, "Failed to read Codex config defaults");
|
||||
}
|
||||
|
||||
if (configReadDefaults.model && configReadDefaults.thinkingOptionId) {
|
||||
return configReadDefaults;
|
||||
}
|
||||
|
||||
let savedConfigDefaults: CodexConfiguredDefaults = {};
|
||||
try {
|
||||
const response = toObjectRecord(await client.request("getUserSavedConfig", {}));
|
||||
@@ -3141,7 +3119,26 @@ async function readCodexConfiguredDefaults(
|
||||
logger.debug({ error }, "Failed to read Codex saved config defaults");
|
||||
}
|
||||
|
||||
return mergeCodexConfiguredDefaults(configReadDefaults, savedConfigDefaults);
|
||||
if (savedConfigDefaults.model && savedConfigDefaults.thinkingOptionId) {
|
||||
return savedConfigDefaults;
|
||||
}
|
||||
|
||||
let configReadDefaults: CodexConfiguredDefaults = {};
|
||||
try {
|
||||
const response = toObjectRecord(await client.request("config/read", {}));
|
||||
const config = toObjectRecord(response?.config);
|
||||
const modelValue = typeof config?.model === "string" ? config.model : undefined;
|
||||
const thinkingOptionValue =
|
||||
typeof config?.model_reasoning_effort === "string" ? config.model_reasoning_effort : null;
|
||||
configReadDefaults = {
|
||||
model: normalizeCodexModelId(modelValue),
|
||||
thinkingOptionId: normalizeCodexThinkingOptionId(thinkingOptionValue),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.debug({ error }, "Failed to read Codex config defaults");
|
||||
}
|
||||
|
||||
return mergeCodexConfiguredDefaults(savedConfigDefaults, configReadDefaults);
|
||||
}
|
||||
|
||||
interface CodexSkillPromptBlock {
|
||||
@@ -5122,11 +5119,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
let model = this.config.model;
|
||||
let thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||
if (!model || !thinkingOptionId) {
|
||||
configuredDefaults = await readCodexConfiguredDefaults(
|
||||
this.client,
|
||||
this.logger,
|
||||
this.config.cwd ?? null,
|
||||
);
|
||||
configuredDefaults = await readCodexConfiguredDefaults(this.client, this.logger);
|
||||
}
|
||||
if (!model) {
|
||||
model = configuredDefaults.model;
|
||||
@@ -7330,7 +7323,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
const parsedResponse = CodexModelListResponseSchema.safeParse(rawResponse);
|
||||
const models = parsedResponse.success ? (parsedResponse.data.data ?? []) : [];
|
||||
const configuredDefaults = await runProviderRefreshActivity(context, "config/read", () =>
|
||||
readCodexConfiguredDefaults(client!, this.logger, null),
|
||||
readCodexConfiguredDefaults(client!, this.logger),
|
||||
);
|
||||
const configuredDefaultModelId = configuredDefaults.model;
|
||||
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
|
||||
|
||||
@@ -333,76 +333,6 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
model: TEST_MODEL,
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ variants: { medium: {}, custom: {} }, selected: "medium", expected: "medium" },
|
||||
{ variants: { custom: {} }, selected: "custom", expected: "custom" },
|
||||
{ variants: { high: {} }, selected: "medium", expected: undefined },
|
||||
{ variants: {}, selected: "medium", expected: undefined },
|
||||
])(
|
||||
"model switches resolve the retained variant: $selected / $variants",
|
||||
async ({ variants, selected, expected }) => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const upstream = new TestOpenCodeClient();
|
||||
upstream.providerListResponse = {
|
||||
data: {
|
||||
connected: ["test"],
|
||||
all: [
|
||||
{
|
||||
id: "test",
|
||||
name: "Test",
|
||||
source: "api",
|
||||
models: { target: { name: "Target", variants } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
runtime.enqueueClient(upstream);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
model: "test/source",
|
||||
thinkingOptionId: selected,
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await session.setModel!("test/target");
|
||||
expect((await session.getRuntimeInfo()).thinkingOptionId).toBe(expected ?? null);
|
||||
expect(events).toContainEqual({
|
||||
type: "thinking_option_changed",
|
||||
provider: "opencode",
|
||||
thinkingOptionId: expected ?? null,
|
||||
});
|
||||
upstream.sessionPromptAsyncEvents = [
|
||||
{ type: "session.idle", properties: { sessionID: "session-1" } },
|
||||
];
|
||||
await collectTurnEvents(streamSession(session, "Use the selected model"));
|
||||
expect(upstream.calls.sessionPromptAsync).toEqual([
|
||||
expect.objectContaining({
|
||||
model: { providerID: "test", modelID: "target" },
|
||||
...(expected ? { variant: expected } : {}),
|
||||
}),
|
||||
]);
|
||||
if (!expected) expect(upstream.calls.sessionPromptAsync[0]).not.toHaveProperty("variant");
|
||||
await session.setThinkingOption!("medium");
|
||||
await expect(session.setModel!("test/missing")).rejects.toThrow(
|
||||
"OpenCode model unavailable",
|
||||
);
|
||||
upstream.providerListResponse = { error: "Catalog unavailable" };
|
||||
await expect(session.setModel!("test/source")).rejects.toThrow("Catalog unavailable");
|
||||
expect((await session.getRuntimeInfo()).model).toBe("test/target");
|
||||
} finally {
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("usage reference follows the active OpenCode model and OAuth account", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
@@ -456,13 +386,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "model_changed",
|
||||
provider: "opencode",
|
||||
runtimeInfo: {
|
||||
provider: "opencode",
|
||||
sessionId,
|
||||
model: "openai/gpt-5",
|
||||
modeId: null,
|
||||
thinkingOptionId: null,
|
||||
},
|
||||
runtimeInfo: { provider: "opencode", sessionId, model: "openai/gpt-5", modeId: null },
|
||||
});
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
|
||||
@@ -3616,7 +3616,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
sessionId: this.sessionId,
|
||||
model: this.config.model ?? null,
|
||||
modeId: this.currentMode,
|
||||
thinkingOptionId: this.config.thinkingOptionId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3628,32 +3627,12 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
await this.reconnectIfServerExited();
|
||||
const normalizedModelId =
|
||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
||||
let variant = this.config.thinkingOptionId;
|
||||
if (!normalizedModelId) {
|
||||
variant = undefined;
|
||||
} else if (variant) {
|
||||
const model = this.parseModel(normalizedModelId);
|
||||
const response = await this.client.provider.list({ directory: this.config.cwd });
|
||||
if (response.error)
|
||||
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
|
||||
const provider = response.data?.all.find((entry) => entry.id === model?.providerID);
|
||||
const target = model && provider?.models[model.modelID];
|
||||
if (!target) throw new Error(`OpenCode model unavailable: ${normalizedModelId}`);
|
||||
if (!Object.hasOwn(target.variants ?? {}, variant)) variant = undefined;
|
||||
}
|
||||
this.config.model = normalizedModelId ?? undefined;
|
||||
this.config.thinkingOptionId = variant;
|
||||
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
|
||||
this.config.model,
|
||||
);
|
||||
this.notifySubscribers({
|
||||
type: "thinking_option_changed",
|
||||
provider: "opencode",
|
||||
thinkingOptionId: variant ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||
|
||||
@@ -355,8 +355,7 @@ test.each([
|
||||
await resumed?.close();
|
||||
await original?.close();
|
||||
await client.shutdown();
|
||||
// OpenCode can still hold its ripgrep download open on Windows right after shutdown.
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
240_000,
|
||||
|
||||
@@ -221,15 +221,6 @@ function deriveOpencodeTaskDetail(
|
||||
};
|
||||
}
|
||||
|
||||
function toOpencodeEditInput(value: { filePath: string; oldString?: string; newString?: string }) {
|
||||
return {
|
||||
filePath: value.filePath,
|
||||
oldString: nonEmptyString(value.oldString),
|
||||
newString: nonEmptyString(value.newString),
|
||||
unifiedDiff: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const OpencodeEditInputSchema = z.union([
|
||||
z
|
||||
.object({
|
||||
@@ -238,12 +229,12 @@ const OpencodeEditInputSchema = z.union([
|
||||
newString: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform(toOpencodeEditInput),
|
||||
// OpenCode v2's edit tool names the file `path`.
|
||||
z
|
||||
.object({ path: z.string(), oldString: z.string(), newString: z.string() })
|
||||
.passthrough()
|
||||
.transform((value) => toOpencodeEditInput({ ...value, filePath: value.path })),
|
||||
.transform((value) => ({
|
||||
filePath: value.filePath,
|
||||
oldString: nonEmptyString(value.oldString),
|
||||
newString: nonEmptyString(value.newString),
|
||||
unifiedDiff: undefined,
|
||||
})),
|
||||
ToolEditInputSchema,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ModelInfo, SessionMessageInfo } from "@opencode/client";
|
||||
import type { SessionMessageInfo } from "@opencode/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
|
||||
@@ -7,89 +7,6 @@ import { OpenCodeV2AgentClient } from "./agent.js";
|
||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||
|
||||
describe("OpenCode v2 session lifecycle", () => {
|
||||
test.each([
|
||||
{ variants: ["medium", "custom"], selected: "medium", expected: "medium" },
|
||||
{ variants: ["custom"], selected: "custom", expected: "custom" },
|
||||
{ variants: ["high"], selected: "medium", expected: undefined },
|
||||
{ variants: [], selected: "medium", expected: undefined },
|
||||
{ variants: ["medium"], selected: "default", expected: undefined },
|
||||
])(
|
||||
"model switches retain only supported variants: $selected / $variants",
|
||||
async ({ variants, selected, expected }) => {
|
||||
const harness = new V2Harness();
|
||||
const target: ModelInfo = {
|
||||
id: "target",
|
||||
modelID: "target",
|
||||
providerID: "test",
|
||||
name: "Target",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: variants.map((id) => ({ id })),
|
||||
time: { released: 1 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 200000, output: 10000 },
|
||||
};
|
||||
harness.info.model = { providerID: "test", id: "source", variant: selected };
|
||||
harness.api.model.list = async () => ({ location: harness.info.location, data: [target] });
|
||||
harness.api.session.switchModel = async ({ model }) => {
|
||||
harness.info.model = model;
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
model: "test/source",
|
||||
thinkingOptionId: selected,
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await session.setModel!("test/target");
|
||||
expect(await session.getRuntimeInfo()).toMatchObject({
|
||||
model: "test/target",
|
||||
thinkingOptionId: expected ?? null,
|
||||
});
|
||||
expect(session.describePersistence().metadata?.thinkingOptionId).toBe(expected);
|
||||
expect(events).toContainEqual({
|
||||
type: "thinking_option_changed",
|
||||
provider: "opencode",
|
||||
thinkingOptionId: expected ?? null,
|
||||
});
|
||||
await expect(session.setModel!("test/missing")).rejects.toThrow(
|
||||
"OpenCode model unavailable",
|
||||
);
|
||||
harness.api.session.switchModel = async () => {
|
||||
throw new Error("Switch failed");
|
||||
};
|
||||
await expect(session.setModel!("test/target")).rejects.toThrow("Switch failed");
|
||||
expect(session.describePersistence().metadata?.thinkingOptionId).toBe(expected);
|
||||
expect(harness.info.model).toEqual({
|
||||
providerID: "test",
|
||||
id: "target",
|
||||
...(expected ? { variant: expected } : {}),
|
||||
});
|
||||
harness.api.session.switchModel = async ({ model }) => {
|
||||
harness.info.model = model;
|
||||
};
|
||||
harness.api.model.default = async () => ({ location: harness.info.location, data: target });
|
||||
await session.setThinkingOption!("unsupported");
|
||||
await session.setModel!(null);
|
||||
expect((await session.getRuntimeInfo()).thinkingOptionId).toBeNull();
|
||||
harness.api.model.list = async () => {
|
||||
throw new Error("Catalog unavailable");
|
||||
};
|
||||
await expect(session.setModel!("test/source")).rejects.toThrow("Catalog unavailable");
|
||||
expect((await session.getRuntimeInfo()).model).toBe("test/target");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("restores each same-directory agent environment on reconnect and resume", async () => {
|
||||
const first = new V2Harness();
|
||||
const second = new V2Harness();
|
||||
|
||||
@@ -276,48 +276,25 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
}
|
||||
async setModel(model: string | null) {
|
||||
await this.reconnectIfExited();
|
||||
const location = { directory: this.config.cwd };
|
||||
const selected = model ? modelRef(model) : (await this.client.model.default({ location })).data;
|
||||
const selected = model
|
||||
? modelRef(model, this.config.thinkingOptionId)
|
||||
: (await this.client.model.default({ location: { directory: this.config.cwd } })).data;
|
||||
if (!selected) throw new Error("OpenCode has no default model");
|
||||
const catalog = await this.client.model.list({ location });
|
||||
const target = catalog.data.find(
|
||||
(entry) =>
|
||||
entry.enabled && entry.providerID === selected.providerID && entry.id === selected.id,
|
||||
);
|
||||
if (!target)
|
||||
throw new Error(`OpenCode model unavailable: ${selected.providerID}/${selected.id}`);
|
||||
const retainedVariant = this.config.thinkingOptionId;
|
||||
const variant =
|
||||
retainedVariant !== "default" && target.variants.some((entry) => entry.id === retainedVariant)
|
||||
? retainedVariant
|
||||
: undefined;
|
||||
const nextModel = {
|
||||
id: selected.id,
|
||||
providerID: selected.providerID,
|
||||
...(variant ? { variant } : {}),
|
||||
};
|
||||
await this.client.session.switchModel({ sessionID: this.id, model: nextModel });
|
||||
this.info.model = nextModel;
|
||||
this.config.model = model ?? undefined;
|
||||
this.config.thinkingOptionId = variant;
|
||||
this.emit({
|
||||
type: "thinking_option_changed",
|
||||
provider: "opencode",
|
||||
thinkingOptionId: variant ?? null,
|
||||
await this.client.session.switchModel({
|
||||
sessionID: this.id,
|
||||
model: {
|
||||
id: selected.id,
|
||||
providerID: selected.providerID,
|
||||
variant: this.config.thinkingOptionId,
|
||||
},
|
||||
});
|
||||
this.config.model = model ?? undefined;
|
||||
this.emit({
|
||||
type: "model_changed",
|
||||
provider: "opencode",
|
||||
runtimeInfo: {
|
||||
provider: "opencode",
|
||||
sessionId: this.id,
|
||||
model: `${selected.providerID}/${selected.id}`,
|
||||
modeId: this.info.agent ?? null,
|
||||
thinkingOptionId: variant ?? null,
|
||||
},
|
||||
runtimeInfo: await this.getRuntimeInfo(),
|
||||
});
|
||||
}
|
||||
|
||||
async setThinkingOption(variant: string | null) {
|
||||
await this.reconnectIfExited();
|
||||
const model = this.info.model;
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { SessionMessageAssistant } from "@opencode/client";
|
||||
import { expect, test } from "vitest";
|
||||
import { V2Timeline } from "./timeline.js";
|
||||
|
||||
function assistant(content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
||||
return {
|
||||
id: "answer",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
time: { created: 2 },
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
test("a completed edit carries the replaced and replacement text", () => {
|
||||
// Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`.
|
||||
const edit = assistant([
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-edit",
|
||||
name: "edit",
|
||||
executed: false,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { path: "a.txt", oldString: "beta", newString: "BETA" },
|
||||
content: [{ type: "text", text: "Edited a.txt (1 replacement)" }],
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "a.txt",
|
||||
patch:
|
||||
"Index: a.txt\n===================================================================\n--- a.txt\n+++ a.txt\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
time: { created: 2, ran: 2, completed: 2 },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(new V2Timeline().messages([edit])).toMatchObject([
|
||||
{
|
||||
item: {
|
||||
type: "tool_call",
|
||||
name: "edit",
|
||||
status: "completed",
|
||||
detail: { type: "edit", filePath: "a.txt", oldString: "beta", newString: "BETA" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
import { once } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
|
||||
// Runs the real `codex` binary against a local Responses endpoint standing in
|
||||
// for a custom provider's OPENAI_BASE_URL, so no login is needed.
|
||||
|
||||
function sse(events: Array<Record<string, unknown>>): string {
|
||||
return events
|
||||
.map((event) => `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function finalAnswer(): string {
|
||||
return sse([
|
||||
{ type: "response.created", response: { id: "resp-1" } },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "msg-1",
|
||||
content: [{ type: "output_text", text: "done" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp-1",
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
input_tokens_details: null,
|
||||
output_tokens: 0,
|
||||
output_tokens_details: null,
|
||||
total_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function startResponsesEndpoint(): Promise<{
|
||||
baseUrl: string;
|
||||
server: Server;
|
||||
requestCount: () => number;
|
||||
}> {
|
||||
let requests = 0;
|
||||
const server = createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
if (req.method !== "POST" || req.url !== "/v1/responses") {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
requests += 1;
|
||||
res.setHeader("content-type", "text/event-stream");
|
||||
res.end(finalAnswer());
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("no server address");
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
server,
|
||||
requestCount: () => requests,
|
||||
};
|
||||
}
|
||||
|
||||
async function closeServer(server: Server): Promise<void> {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
|
||||
describe("Codex custom provider archive", () => {
|
||||
const cleanups: Array<() => void | Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
|
||||
});
|
||||
|
||||
test("archiving hides the session from Import session and unarchiving lists it again", async () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "codex-custom-archive-"));
|
||||
cleanups.push(() => rmSync(root, { recursive: true, force: true }));
|
||||
const codexHome = path.join(root, "codex-home");
|
||||
mkdirSync(codexHome);
|
||||
const cwd = path.join(root, "project");
|
||||
mkdirSync(cwd);
|
||||
const { baseUrl, server } = await startResponsesEndpoint();
|
||||
cleanups.push(() => closeServer(server));
|
||||
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
logger: createTestLogger(),
|
||||
pluginsEnabled: false,
|
||||
providerOverrides: {
|
||||
"my-codex": {
|
||||
extends: "codex",
|
||||
label: "My Codex",
|
||||
env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" },
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanups.push(() => daemon.close());
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
cleanups.push(() => client.close());
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const manager = daemon.daemon.agentManager;
|
||||
|
||||
const agent = await client.createAgent({
|
||||
config: { provider: "my-codex", cwd, model: "mock-model", modeId: "full-access" },
|
||||
});
|
||||
await manager.runAgent(agent.id, "hello from the custom provider");
|
||||
const importable = async () =>
|
||||
(await manager.listImportableSessions({ cwd, providerFilter: new Set(["my-codex"]) }))
|
||||
.sessions;
|
||||
expect(await importable()).toHaveLength(1);
|
||||
|
||||
await client.archiveAgent(agent.id);
|
||||
expect(await importable()).toEqual([]);
|
||||
|
||||
await client.refreshAgent(agent.id);
|
||||
expect(await importable()).toHaveLength(1);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -423,55 +423,6 @@ test("a clean daemon shutdown leaves a completed plugin-provider agent without a
|
||||
expect(persisted?.attentionReason ?? null).not.toBe("error");
|
||||
}, 60_000);
|
||||
|
||||
test("reloading a plugin leaves a completed plugin-provider agent without an error", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-reload-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const pluginDirectory = await createProviderPlugin(root);
|
||||
const workspace = path.join(root, "workspace");
|
||||
await mkdir(workspace, { recursive: true });
|
||||
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
paseoHomeRoot: path.join(root, "daemon"),
|
||||
staticDir: path.join(root, "static"),
|
||||
pluginsEnabled: true,
|
||||
plugins: {
|
||||
"shutdown-provider-plugin": { source: "directory", path: pluginDirectory, enabled: true },
|
||||
},
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.1" });
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: PROVIDER_ID,
|
||||
model: "shutdown-model",
|
||||
cwd: workspace,
|
||||
title: "Reload provider agent",
|
||||
});
|
||||
await client.sendMessage(agent.id, "hello");
|
||||
expect((await client.waitForFinish(agent.id, 30_000)).status).toBe("idle");
|
||||
|
||||
const reloaded = await client.reloadPlugin("shutdown-provider-plugin");
|
||||
expect(reloaded.status).toBe("running");
|
||||
|
||||
const afterReload = await daemon.daemon.agentStorage.get(agent.id);
|
||||
expect(afterReload?.lastError ?? null).toBeNull();
|
||||
expect(afterReload?.attentionReason ?? null).not.toBe("error");
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
expect(JSON.stringify(timeline.entries)).not.toContain("Provider connection closed");
|
||||
|
||||
await client.sendMessage(agent.id, "after reload");
|
||||
expect((await client.waitForFinish(agent.id, 30_000)).status).toBe("idle");
|
||||
}, 60_000);
|
||||
|
||||
test("a provider that never acknowledges session.close does not hold the daemon open", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-stuck-close-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestPaseoDaemon } from "./paseo-daemon.js";
|
||||
|
||||
describe("createTestPaseoDaemon", () => {
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
|
||||
});
|
||||
|
||||
test("keeps a caller-owned home when startup on a taken port fails", async () => {
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-owned-home-"));
|
||||
cleanups.push(() => rm(paseoHomeRoot, { recursive: true, force: true }));
|
||||
const ownedFile = path.join(paseoHomeRoot, "owned-by-caller");
|
||||
await writeFile(ownedFile, "keep");
|
||||
const takenPort = await occupyPort(cleanups);
|
||||
|
||||
await expect(
|
||||
createTestPaseoDaemon({
|
||||
listenPort: takenPort,
|
||||
paseoHomeRoot,
|
||||
staticDir: path.join(paseoHomeRoot, "static"),
|
||||
cleanup: false,
|
||||
mcpEnabled: false,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EADDRINUSE" });
|
||||
|
||||
expect(existsSync(ownedFile)).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("removes the directories it created when startup on a taken port fails", async () => {
|
||||
const scratchTmp = await mkdtemp(path.join(os.tmpdir(), "paseo-daemon-scratch-"));
|
||||
cleanups.push(() => rm(scratchTmp, { recursive: true, force: true }));
|
||||
for (const name of ["TMPDIR", "TMP", "TEMP"]) vi.stubEnv(name, scratchTmp);
|
||||
const takenPort = await occupyPort(cleanups);
|
||||
|
||||
await expect(
|
||||
createTestPaseoDaemon({ listenPort: takenPort, cleanup: false, mcpEnabled: false }),
|
||||
).rejects.toMatchObject({ code: "EADDRINUSE" });
|
||||
|
||||
expect(await readdir(scratchTmp)).toEqual([]);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
async function occupyPort(cleanups: Array<() => Promise<void>>): Promise<number> {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
cleanups.push(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Port holder did not bind a TCP port");
|
||||
}
|
||||
return address.port;
|
||||
}
|
||||
@@ -21,7 +21,6 @@ interface TestPaseoDaemonOptions {
|
||||
downloadTokenTtlMs?: number;
|
||||
corsAllowedOrigins?: string[];
|
||||
listen?: string;
|
||||
listenPort?: number;
|
||||
logger?: Parameters<typeof createPaseoDaemon>[1];
|
||||
mcpEnabled?: boolean;
|
||||
mcpDebug?: boolean;
|
||||
@@ -100,8 +99,7 @@ export async function createTestPaseoDaemon(
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const { config, paseoHomeRoot, paseoHome, staticDir, createdDirs } =
|
||||
await prepareTestDaemonConfig(options);
|
||||
const { config, paseoHomeRoot, paseoHome, staticDir } = await prepareTestDaemonConfig(options);
|
||||
const logger = options.logger ?? pino({ level: "silent" });
|
||||
const daemon = await createPaseoDaemon(config, logger, {
|
||||
builtinPlugins: options.builtinPlugins ?? new BuiltinPluginLoader(undefined, []),
|
||||
@@ -122,7 +120,10 @@ export async function createTestPaseoDaemon(
|
||||
await daemon.agentManager.flush().catch(() => undefined);
|
||||
if (options.cleanup ?? true) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await removeDirs([paseoHomeRoot, staticDir]);
|
||||
await Promise.all([
|
||||
rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
|
||||
rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,8 +138,10 @@ export async function createTestPaseoDaemon(
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await daemon.stop().catch(() => undefined);
|
||||
// A failed attempt removes only what it created: a caller-supplied home keeps its serverId.
|
||||
await removeDirs(createdDirs);
|
||||
await Promise.all([
|
||||
rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
|
||||
rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
|
||||
]);
|
||||
|
||||
if (
|
||||
(!isAddressInUseError(error) && !isStartupTimeoutError(error)) ||
|
||||
@@ -157,33 +160,19 @@ interface PreparedTestDaemonConfig {
|
||||
paseoHomeRoot: string;
|
||||
paseoHome: string;
|
||||
staticDir: string;
|
||||
createdDirs: string[];
|
||||
}
|
||||
|
||||
async function removeDirs(dirs: string[]): Promise<void> {
|
||||
await Promise.all(
|
||||
dirs.map((dir) => rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })),
|
||||
);
|
||||
}
|
||||
|
||||
async function createTempDir(prefix: string, createdDirs: string[]): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
createdDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function prepareTestDaemonConfig(
|
||||
options: TestPaseoDaemonOptions,
|
||||
): Promise<PreparedTestDaemonConfig> {
|
||||
const createdDirs: string[] = [];
|
||||
const paseoHomeRoot = options.paseoHomeRoot ?? (await createTempDir("paseo-home-", createdDirs));
|
||||
const paseoHomeRoot =
|
||||
options.paseoHomeRoot ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-home-")));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const staticDir = options.staticDir ?? (await createTempDir("paseo-static-", createdDirs));
|
||||
const staticDir = options.staticDir ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-static-")));
|
||||
const listenHost = options.listen ?? "127.0.0.1";
|
||||
const listenPort = options.listenPort ?? 0;
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: `${listenHost}:${listenPort}`,
|
||||
listen: `${listenHost}:0`,
|
||||
paseoHome,
|
||||
daemonVersion: options.daemonVersion,
|
||||
desktopManaged: options.desktopManaged,
|
||||
@@ -218,7 +207,7 @@ async function prepareTestDaemonConfig(
|
||||
pluginsEnabled: options.pluginsEnabled,
|
||||
plugins: options.plugins,
|
||||
};
|
||||
return { config, paseoHomeRoot, paseoHome, staticDir, createdDirs };
|
||||
return { config, paseoHomeRoot, paseoHome, staticDir };
|
||||
}
|
||||
|
||||
function isAddressInUseError(error: unknown): boolean {
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
// Browser compatibility tests run a real daemon in a separate Node runtime.
|
||||
//
|
||||
// argv: <daemonVersion> [port] [paseoHomeRoot]
|
||||
// A fixed port and a caller-owned home let a test stop this daemon and start a
|
||||
// replacement under the same endpoint and serverId.
|
||||
import path from "node:path";
|
||||
import { createTestPaseoDaemon } from "./paseo-daemon.js";
|
||||
|
||||
const [, , daemonVersion, portArg, paseoHomeRoot] = process.argv;
|
||||
const listenPort = portArg ? Number(portArg) : 0;
|
||||
if (!Number.isInteger(listenPort) || listenPort < 0) {
|
||||
throw new Error(`Invalid versioned daemon port: ${portArg ?? ""}`);
|
||||
}
|
||||
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
daemonVersion,
|
||||
...(listenPort > 0 ? { listenPort } : {}),
|
||||
...(paseoHomeRoot
|
||||
? {
|
||||
paseoHomeRoot,
|
||||
staticDir: path.join(paseoHomeRoot, "static"),
|
||||
// The caller removes the home so a replacement daemon keeps the same serverId.
|
||||
cleanup: false,
|
||||
}
|
||||
: {}),
|
||||
daemonVersion: process.argv[2],
|
||||
pluginsEnabled: true,
|
||||
mcpEnabled: false,
|
||||
corsAllowedOrigins: ["*"],
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"interval": "monthly",
|
||||
"intervalCount": 1,
|
||||
"unitAmount": 0,
|
||||
"currency": "usd",
|
||||
"currency": "eur",
|
||||
"tooltip": null
|
||||
}
|
||||
]
|
||||
@@ -57,7 +57,7 @@
|
||||
"interval": "monthly",
|
||||
"intervalCount": 1,
|
||||
"unitAmount": 1500,
|
||||
"currency": "usd",
|
||||
"currency": "eur",
|
||||
"tooltip": "Seats are Hub members and pending invitations. People who only trigger agents through GitHub, Slack, or Discord do not count as seats."
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
// The documented `GET /api/billing/plans` response, copied from getpaseo/hub docs/public-api.md.
|
||||
import documentedResponse from "./hub-plans.fixture.json" with { type: "json" };
|
||||
import { formatPlanPrice, parseHubPlansResponse, planPriceQualifier } from "./hub-plans";
|
||||
import { parseHubPlansResponse } from "./hub-plans";
|
||||
|
||||
describe("parseHubPlansResponse", () => {
|
||||
it("reads Free and Pro, their monthly prices, and the figures each includes", () => {
|
||||
@@ -18,7 +18,7 @@ describe("parseHubPlansResponse", () => {
|
||||
expect(plans.paid.name).toBe("Pro");
|
||||
expect(plans.paid.included).toEqual({ seats: null, executionsPerMonth: null });
|
||||
expect(plans.paid.price.unitAmount).toBe(1500);
|
||||
expect(plans.paid.price.currency).toBe("usd");
|
||||
expect(plans.paid.price.currency).toBe("eur");
|
||||
expect(plans.paid.billing.unit.label).toBe("seat");
|
||||
});
|
||||
|
||||
@@ -52,14 +52,3 @@ describe("parseHubPlansResponse", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan price copy", () => {
|
||||
it("shows Free as $0 a month and Pro as $15 per seat a month", () => {
|
||||
const plans = parseHubPlansResponse(documentedResponse);
|
||||
|
||||
expect(formatPlanPrice(plans.free.price)).toBe("$0");
|
||||
expect(planPriceQualifier(plans.free)).toBe("/ month");
|
||||
expect(formatPlanPrice(plans.paid.price)).toBe("$15");
|
||||
expect(planPriceQualifier(plans.paid)).toBe("per seat / month");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,23 +55,6 @@ export const getHubPlans = createServerFn({ method: "GET" }).handler(async () =>
|
||||
return parseHubPlansResponse(await response.json());
|
||||
});
|
||||
|
||||
/** The plan's monthly figure in its own currency, e.g. "$15". */
|
||||
export function formatPlanPrice(price: HubPlanOffer["price"]): string {
|
||||
return new Intl.NumberFormat("en", {
|
||||
style: "currency",
|
||||
currency: price.currency.toUpperCase(),
|
||||
minimumFractionDigits: 0,
|
||||
}).format(price.unitAmount / 100);
|
||||
}
|
||||
|
||||
/** What the figure is for: "per seat / month" when each seat is charged, "/ month" when the
|
||||
* plan costs nothing, since a free plan has no per-seat charge to describe. */
|
||||
export function planPriceQualifier(plan: HubPlanOffer): string {
|
||||
const period = plan.price.intervalCount === 1 ? "month" : `${plan.price.intervalCount} months`;
|
||||
if (plan.price.unitAmount === 0) return `/ ${period}`;
|
||||
return `per ${plan.billing.unit.label} / ${period}`;
|
||||
}
|
||||
|
||||
function selectOffer(plans: readonly HubBillingPlan[], slug: string): HubPlanOffer {
|
||||
const plan = plans.find((candidate) => candidate.slug === slug);
|
||||
if (plan === undefined) throw new Error(`Hub plan "${slug}" is unavailable`);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user