Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andthecardcheat 48e5a69845 Connect to the open Advanced URI in Direct connection
With Advanced open, Connect built the connection from the Host and Port
fields and ignored a tcp:// URI in the Advanced field. A blank Host
failed with "Host is required", and an edited URI connected to the
address the fields still held. Connect now uses the URI shown while
Advanced is open; the Password field still supplies a password the URI
does not carry.

Closes #5495

Co-authored-by: thecardcheat <pitt.joshua.miller@gmail.com>
2026-09-28 18:57:08 +02:00
101 changed files with 595 additions and 2424 deletions
-18
View File
@@ -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,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();
}
});
});
@@ -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 });
}
});
@@ -14,7 +14,6 @@ import {
expectConflictPreservesDraft,
discardTitle,
returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable,
} from "../support/helpers/plugin-settings";
@@ -44,11 +43,6 @@ test("another client's save preserves a conflicting draft", async ({ page }) =>
await discardTitle(page);
});
test("Back to plugins returns from a plugin settings screen", async ({ page }) => {
await openDisplaySettings(page);
await returnToPluginsPage(page);
});
test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -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");
});
});
@@ -27,6 +27,8 @@ import {
openCompactSettingsHost,
expectAddHostMethodOptions,
fillDirectHostUri,
fillDirectHostFields,
connectDirectHost,
expectDirectHostFormValues,
expectDirectHostSslEnabled,
expectDirectHostUriValue,
@@ -112,6 +114,36 @@ test.describe("Settings sidebar navigation", () => {
await expectDirectHostUriHidden(page);
});
test("direct connection connects to the open Advanced URI when Host is blank", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await toggleHostAdvanced(page);
await fillDirectHostUri(page, `tcp://127.0.0.1:${getE2EDaemonPort()}`);
await connectDirectHost(page);
});
test("direct connection connects to an edited Advanced URI instead of the fields", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await fillDirectHostFields(page, { host: "127.0.0.1", port: "9" });
await toggleHostAdvanced(page);
await expectDirectHostUriValue(page, "tcp://127.0.0.1:9");
await fillDirectHostUri(page, `tcp://127.0.0.1:${getE2EDaemonPort()}`);
await connectDirectHost(page);
});
test("Escape lets settings dropdowns and modals close before leaving settings", async ({
page,
}) => {
@@ -1,11 +1,7 @@
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test, type Page } from "../support/fixtures";
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
import {
openAgentRoute,
seedMockAgentWorkspace,
seedRunningMockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
const REPORT_ID = "fixture:fixture-account";
@@ -46,30 +42,6 @@ async function openMockAgent(page: Page) {
}
test.describe("usage composer pill", () => {
test("shows the report when an existing running chat opens", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42)]],
});
const session = await seedRunningMockAgentWorkspace({
repoPrefix: "usage-running-",
title: "Running usage pill",
initialPrompt: "Show usage during the turn.",
model: "one-minute-stream",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByTestId("usage-composer-pill")).toContainText("42%", {
timeout: 20_000,
});
expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId });
} finally {
await session.cleanup();
}
});
test("opens the cached report without a request and shows when it was fetched", async ({
page,
}) => {
@@ -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(
@@ -3,8 +3,6 @@ import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
@@ -107,13 +105,6 @@ export async function returnAndReopenSettings(page: Page) {
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
}
export async function returnToPluginsPage(page: Page) {
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
await expectAppRoute(page, buildSettingsHostSectionRoute(getServerId(), "plugins"));
await expect(
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
).toBeVisible();
}
export async function expectSettingsUnavailable(page: Page) {
await expect(
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
@@ -253,6 +253,19 @@ export async function fillDirectHostUri(page: Page, uri: string): Promise<void>
await page.getByTestId("direct-host-uri-input").fill(uri);
}
export async function fillDirectHostFields(
page: Page,
fields: { host: string; port: string },
): Promise<void> {
await page.getByTestId("direct-host-input").fill(fields.host);
await page.getByTestId("direct-port-input").fill(fields.port);
}
export async function connectDirectHost(page: Page): Promise<void> {
await page.getByTestId("direct-host-submit").click();
await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 });
}
export async function expectDirectHostFormValues(
page: Page,
fields: { host: string; port: string; password: string },
@@ -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}`;
+13 -7
View File
@@ -195,6 +195,12 @@ function draftFromConnectionUri(uri: string): DirectConnectionDraft {
};
}
// The Password field stays visible under Advanced, so it fills in a URI that carries none.
function draftFromAdvancedUri(uri: string, fieldPassword: string): DirectConnectionDraft {
const draft = draftFromConnectionUri(uri);
return draft.password ? draft : { ...draft, password: fieldPassword };
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
@@ -405,18 +411,18 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const handleSave = useCallback(async () => {
if (isSaving) return;
const relayUri = isAdvancedOpen ? advancedUri.trim() : "";
if (relayUri.startsWith("relay://") || relayUri.includes("#connect=")) {
await handleSaveRelay(relayUri);
const advancedInput = isAdvancedOpen ? advancedUri.trim() : "";
if (advancedInput.startsWith("relay://") || advancedInput.includes("#connect=")) {
await handleSaveRelay(advancedInput);
return;
}
let connection: PreparedDirectConnection;
try {
connection = prepareDirectConnection(
{ host, port, useTls, password },
directConnectionLabels,
);
const draft = advancedInput
? draftFromAdvancedUri(advancedInput, password)
: { host, port, useTls, password };
connection = prepareDirectConnection(draft, directConnectionLabels);
} catch (error) {
const message =
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
+6 -12
View File
@@ -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",
});
});
});
+18 -14
View File
@@ -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}
/>
+6 -6
View File
@@ -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,
@@ -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;
@@ -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;
}
-2
View File
@@ -1522,8 +1522,6 @@ export const ar: TranslationResources = {
noFiles: "لم يتم العثور على ملفات أو أدلة",
noCommands: "لم يتم العثور على أي أوامر",
failedToLoad: "فشل التحميل",
chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر",
chooseModelForCommands: "اختر نموذجًا لعرض الأوامر",
},
loadOlderHistory: {
failed: "تعذر تحميل السجل الأقدم",
-3
View File
@@ -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",
@@ -2071,7 +2069,6 @@ export const en = {
offline: "Connect to this host to open plugin settings.",
update: "Update this host to use plugin settings.",
unavailable: "This plugin settings screen is unavailable.",
backToPlugins: "Back to plugins",
},
trustedTitle: "Plugins are trusted code",
trustedDescription:
-2
View File
@@ -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",
-2
View File
@@ -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",
-2
View File
@@ -1539,8 +1539,6 @@ export const ja: TranslationResources = {
noFiles: "ファイルまたはディレクトリが見つかりません",
noCommands: "コマンドが見つかりません",
failedToLoad: "読み込みに失敗しました",
chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください",
chooseModelForCommands: "コマンドを表示するにはモデルを選択してください",
},
loadOlderHistory: {
failed: "古い履歴を読み込めませんでした",
-2
View File
@@ -1532,8 +1532,6 @@ export const ko: TranslationResources = {
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
noCommands: "명령을 찾을 수 없습니다",
failedToLoad: "불러오지 못했습니다",
chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요",
chooseModelForCommands: "명령을 보려면 모델을 선택하세요",
},
loadOlderHistory: {
failed: "이전 기록을 불러올 수 없습니다",
@@ -18,7 +18,6 @@ export const pluginSettings = {
offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.",
update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.",
unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.",
backToPlugins: "العودة إلى الإضافات",
},
title: "الإضافات",
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
@@ -73,7 +72,6 @@ export const pluginSettings = {
offline: "Conéctate a este host para abrir los ajustes del plugin.",
update: "Actualiza este host para usar los ajustes del plugin.",
unavailable: "Esta pantalla de ajustes del plugin no está disponible.",
backToPlugins: "Volver a plugins",
},
title: "Plugins",
trustedTitle: "Los plugins son código de confianza",
@@ -128,7 +126,6 @@ export const pluginSettings = {
offline: "Connectez-vous à cet hôte pour ouvrir les paramètres du plugin.",
update: "Mettez à jour cet hôte pour utiliser les paramètres du plugin.",
unavailable: "Cet écran de paramètres du plugin est indisponible.",
backToPlugins: "Retour aux plugins",
},
title: "Plugins",
trustedTitle: "Les plugins sont du code de confiance",
@@ -183,7 +180,6 @@ export const pluginSettings = {
offline: "このホストに接続してプラグイン設定を開いてください。",
update: "プラグイン設定を使用するには、このホストを更新してください。",
unavailable: "このプラグイン設定画面は利用できません。",
backToPlugins: "プラグインに戻る",
},
title: "プラグイン",
trustedTitle: "プラグインは信頼済みコードです",
@@ -238,7 +234,6 @@ export const pluginSettings = {
offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.",
update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.",
unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.",
backToPlugins: "플러그인으로 돌아가기",
},
title: "플러그인",
trustedTitle: "플러그인은 신뢰하는 코드입니다",
@@ -292,7 +287,6 @@ export const pluginSettings = {
offline: "Conecte-se a este host para abrir as configurações do plugin.",
update: "Atualize este host para usar as configurações do plugin.",
unavailable: "Esta tela de configurações do plugin está indisponível.",
backToPlugins: "Voltar para plugins",
},
title: "Plugins",
trustedTitle: "Plugins são código confiável",
@@ -347,7 +341,6 @@ export const pluginSettings = {
offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.",
update: "Обновите этот хост, чтобы использовать настройки плагина.",
unavailable: "Этот экран настроек плагина недоступен.",
backToPlugins: "Вернуться к плагинам",
},
title: "Плагины",
trustedTitle: "Плагины — доверенный код",
@@ -402,7 +395,6 @@ export const pluginSettings = {
offline: "连接到此主机以打开插件设置。",
update: "更新此主机以使用插件设置。",
unavailable: "此插件设置页面不可用。",
backToPlugins: "返回插件",
},
title: "插件",
trustedTitle: "插件是受信任的代码",
-2
View File
@@ -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",
-2
View File
@@ -1551,8 +1551,6 @@ export const ru: TranslationResources = {
noFiles: "Файлы и каталоги не найдены",
noCommands: "Команды не найдены",
failedToLoad: "Не удалось загрузить",
chooseProjectForCommands: "Выберите проект, чтобы увидеть команды",
chooseModelForCommands: "Выберите модель, чтобы увидеть команды",
},
loadOlderHistory: {
failed: "Не удалось загрузить старую историю.",
-2
View File
@@ -1505,8 +1505,6 @@ export const zhCN: TranslationResources = {
noFiles: "没有找到文件或目录",
noCommands: "没有找到 commands",
failedToLoad: "加载失败",
chooseProjectForCommands: "选择项目以查看命令",
chooseModelForCommands: "选择模型以查看命令",
},
loadOlderHistory: {
failed: "无法加载更早历史",
+3 -27
View File
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { Platform, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { router } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import type { PluginHostProps } from "@getpaseo/plugin/client";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
@@ -128,30 +127,7 @@ function SettingsContent({
}
const ThemedSettingsContent = withUnistyles(SettingsContent);
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function PluginSettingsContent({
onBackToPlugins,
showBackToPlugins,
...identity
}: SettingsIdentity & { onBackToPlugins: () => void; showBackToPlugins: boolean }) {
const { t } = useTranslation();
return (
<View>
{showBackToPlugins ? (
<Button
onPress={onBackToPlugins}
variant="ghost"
size="sm"
leftIcon={ArrowLeft}
style={styles.backButton}
>
{t("settings.plugins.screens.backToPlugins")}
</Button>
) : null}
<ThemedSettingsContent {...identity} uniProps={themeMapping} />
</View>
);
export function PluginSettingsContent(props: SettingsIdentity) {
return <ThemedSettingsContent {...props} uniProps={themeMapping} />;
}
const styles = StyleSheet.create((theme) => ({
message: { color: theme.colors.foregroundMuted },
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
}));
const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } }));
@@ -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: {
@@ -2422,7 +2422,7 @@ export function NewWorkspaceScreen({
clearDraft={handleClearDraft}
autoFocus
autoFocusKey={launchFocusKey}
commandDraft={composerState?.commandDraft}
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
/>
);
@@ -1333,8 +1333,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
serverId={view.serverId}
pluginId={view.pluginId}
screenId={view.screenId}
onBackToPlugins={handleBackFromDetail}
showBackToPlugins={!isCompactLayout}
/>
);
if (view.kind === "host") {
@@ -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";
+1 -1
View File
@@ -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";
+6 -10
View File
@@ -31,13 +31,8 @@ function usageReportsQueryKey(serverId: string) {
return ["usage", "reports", serverId] as const;
}
function agentUsageQueryKey(
serverId: string,
agentId: string,
model: string | null,
isRunning: boolean,
) {
return ["usage", "agent", serverId, agentId, model, isRunning] as const;
function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) {
return ["usage", "agent", serverId, agentId, model] as const;
}
function requireClient(serverId: string) {
@@ -165,7 +160,8 @@ export function useUsageByHost(): {
/**
* The usage report for the account an agent is spending. The daemon resolves the
* report ID, which is re-resolved when the model changes (a new key) and when a
* turn completes (a new query key). The report itself is read from the shared cache.
* turn completes (the query is paused while the agent runs, and its data is always
* stale, so resuming refetches). The report itself is read from the shared cache.
*/
export function useAgentUsage(
serverId: string,
@@ -180,10 +176,10 @@ export function useAgentUsage(
}),
);
const identity = useFetchQuery({
queryKey: agentUsageQueryKey(serverId, agentId, model, isRunning),
queryKey: agentUsageQueryKey(serverId, agentId, model),
queryFn: async () =>
(await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId,
enabled: isConnected && isSupported,
enabled: isConnected && isSupported && !isRunning,
dataShape: "value",
staleTimeMs: 0,
});
+1 -17
View File
@@ -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");
+53 -49
View File
@@ -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 {
+2 -7
View File
@@ -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();
}
}
-64
View File
@@ -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({
+6 -19
View File
@@ -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();
+1 -7
View File
@@ -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({
+6 -2
View File
@@ -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 {
@@ -50,7 +50,6 @@ import {
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type UsageReference,
type ImportedTimelineEntry,
type ImportableProviderSession,
type ListImportableSessionsOptions,
@@ -1168,24 +1167,6 @@ export class AgentManager {
return agent ? { ...agent } : null;
}
async resolveUsageReference(
id: string,
): Promise<{ found: boolean; reference: UsageReference | null }> {
const agent = this.getAgent(id);
const stored = agent?.session ? null : await this.registry?.get(id);
const source = stored ?? agent;
if (!source) return { found: false, reference: null };
const config = stored ? buildStoredAgentConfig(stored) : agent!.config;
const reference =
(await this.clients.get(source.provider)?.resolveUsageReference?.({
config,
runtimeInfo: source.runtimeInfo ?? null,
persistence: source.persistence ?? null,
session: agent?.session ?? null,
})) ?? null;
return { found: true, reference };
}
async waitForAgentClose(agentId: string): Promise<void> {
// Loading during reload must wait for the replacement, not resume another writer.
await this.lifecycleMutationTails.get(agentId);
@@ -1962,7 +1943,6 @@ export class AgentManager {
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -673,6 +673,8 @@ export interface AgentSession {
/** New provider-owned rows to commit on registration. streamHistory must also
* replay them at their original timestamps; restored sessions omit old rows. */
readonly initialTimeline?: ImportedTimelineEntry[];
/** Resolved at fetch time because model and credentials may change during a session. */
getUsageReference?(): Promise<UsageReference | null>;
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -748,13 +750,6 @@ export interface ResolveAgentDefaultModeInput {
export interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
/** Account used by the agent. Resolving this must not start or load a session. */
resolveUsageReference?(input: {
config: AgentSessionConfig;
runtimeInfo: AgentRuntimeInfo | null;
persistence: AgentPersistenceHandle | null;
session: AgentSession | null;
}): Promise<UsageReference | null>;
createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
@@ -406,12 +406,7 @@ describe("PluginAgentClientRegistry", () => {
cwd: "/workspace",
});
try {
expect(
await client.resolveUsageReference?.({
config: { provider: harness.registration.id, cwd: "/workspace" },
session,
}),
).toBeNull();
expect(await session.getUsageReference?.()).toBeNull();
expect(harness.inputs.some((input) => input.type === "session.usage_reference")).toBe(false);
} finally {
await session.close();
@@ -454,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") {
@@ -897,15 +889,6 @@ class PluginAgentClient implements AgentClient {
return agentCapabilities(this.runtime.negotiatedCapabilities);
}
async resolveUsageReference({
session,
}: {
config: AgentSessionConfig;
session: AgentSession | null;
}) {
return session instanceof PluginAgentSession ? session.getPluginUsageReference() : null;
}
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
@@ -1105,7 +1088,7 @@ class PluginAgentSession implements AgentSession {
return agentCapabilities(this.bridge.negotiatedCapabilities);
}
getPluginUsageReference(): Promise<UsageReference | null> {
getUsageReference(): Promise<UsageReference | null> {
return this.bridge.getUsageReference();
}
@@ -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, {
@@ -458,6 +458,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
}
},
getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()),
getUsageReference: inner.getUsageReference?.bind(inner),
getAvailableModes: () => inner.getAvailableModes(),
getCurrentMode: () => inner.getCurrentMode(),
setMode: (modeId) => inner.setMode(modeId),
@@ -487,30 +488,13 @@ 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);
// An alias's live session can carry launch env that is fresher than stored config.
const sessions = new WeakMap<AgentSession, AgentSession>();
const wrapSession = (session: AgentSession) => {
const wrapped = wrapSessionProvider(provider, session);
sessions.set(wrapped, session);
return wrapped;
};
return {
provider,
capabilities: inner.capabilities,
resolveUsageReference: inner.resolveUsageReference
? ({ config, runtimeInfo, persistence, session }) =>
inner.resolveUsageReference!({
config: { ...config, provider: inner.provider },
runtimeInfo,
persistence,
session: session ? (sessions.get(session) ?? null) : null,
})
: undefined,
createSession: async (config, launchContext) =>
wrapSession(
wrapSessionProvider(
provider,
await inner.createSession(
{
...config,
@@ -520,7 +504,8 @@ function wrapClientProvider(
),
),
resumeSession: async (handle, overrides, launchContext, options) =>
wrapSession(
wrapSessionProvider(
provider,
await inner.resumeSession(
{
...handle,
@@ -582,7 +567,7 @@ function wrapClientProvider(
}
return {
...imported,
session: wrapSession(imported.session),
session: wrapSessionProvider(provider, imported.session),
config: {
...imported.config,
provider,
@@ -591,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),
@@ -224,17 +224,8 @@ function createSessionWithConfig(
test("ACP usage reference uses the provider ID", async () => {
for (const provider of ["copilot", "cursor", "kimi", "custom-source"]) {
const client = new ACPAgentClient({
provider,
logger: createTestLogger(),
defaultCommand: ["unused"],
});
expect(
await client.resolveUsageReference({
config: { provider, cwd: "/tmp/paseo-acp-test" },
session: null,
}),
).toEqual({ source: provider, input: {} });
const session = createSessionWithConfig({ provider });
expect(await session.getUsageReference()).toEqual({ source: provider, input: {} });
}
});
@@ -882,9 +882,6 @@ function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): b
export class ACPAgentClient implements AgentClient {
readonly provider: string;
readonly capabilities: AgentCapabilityFlags;
async resolveUsageReference() {
return { source: this.provider, input: {} };
}
readonly resolveCreateConfig = resolveACPCreateConfig;
readonly isCreateConfigUnattended = isACPCreateConfigUnattended;
@@ -1636,6 +1633,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
readonly provider: string;
readonly capabilities: AgentCapabilityFlags;
async getUsageReference() {
return { source: this.provider, input: {} };
}
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly defaultCommand: [string, ...string[]];
@@ -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");
@@ -531,16 +529,16 @@ describe("ClaudeAgentClient binary resolution", () => {
const logger = createTestLogger();
test("Claude usage reference follows CLAUDE_CONFIG_DIR and excludes API overrides", async () => {
const config = { provider: "claude", cwd: process.cwd() };
const client = new ClaudeAgentClient({
logger,
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: "/accounts/second" } },
resolveBinary: async () => "/test/claude/bin",
});
expect(await client.resolveUsageReference?.({ config, session: null })).toEqual({
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
const session = await client.createSession(
{ provider: "claude", cwd: process.cwd() },
{ env: { CLAUDE_CONFIG_DIR: "/accounts/second" } },
);
expect(await session.getUsageReference?.()).toEqual({
source: "claude",
input: { configDir: "/accounts/second" },
});
await session.close();
});
test.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"])(
@@ -548,15 +546,14 @@ describe("ClaudeAgentClient binary resolution", () => {
async (name) => {
const client = new ClaudeAgentClient({
logger,
runtimeSettings: { env: { [name]: "override" } },
resolveBinary: async () => "/test/claude/bin",
});
expect(
await client.resolveUsageReference?.({
config: { provider: "claude", cwd: process.cwd() },
session: null,
}),
).toBeNull();
const session = await client.createSession(
{ provider: "claude", cwd: process.cwd() },
{ env: { [name]: "override" } },
);
expect(await session.getUsageReference?.()).toBeNull();
await session.close();
},
);
@@ -570,16 +567,15 @@ describe("ClaudeAgentClient binary resolution", () => {
},
},
});
const client = registry["work-claude"].createClient(logger);
expect(
await client.resolveUsageReference?.({
config: { provider: "work-claude", cwd: process.cwd() },
session: null,
}),
).toEqual({
const session = await registry["work-claude"].createClient(logger).createSession({
provider: "work-claude",
cwd: process.cwd(),
});
expect(await session.getUsageReference?.()).toEqual({
source: "claude",
input: { configDir: "/accounts/work" },
});
await session.close();
});
test("resolves the installed Claude Code version", async () => {
@@ -1293,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({
@@ -1519,15 +1519,6 @@ export class ClaudeAgentClient implements AgentClient {
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
}
async resolveUsageReference() {
const env = createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings });
if (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) return null;
return {
source: "claude",
input: { configDir: env.CLAUDE_CONFIG_DIR || path.join(env.HOME || homedir(), ".claude") },
};
}
resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition {
return resolveConfiguredClaudeModel(model);
}
@@ -2051,6 +2042,15 @@ class ClaudeAgentSession implements AgentSession {
readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES;
async getUsageReference() {
const env = this.buildSdkEnv();
if (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) return null;
return {
source: "claude",
input: { configDir: env.CLAUDE_CONFIG_DIR || path.join(env.HOME || homedir(), ".claude") },
};
}
private readonly config: ClaudeAgentConfig;
private readonly launchEnv?: Record<string, string>;
private readonly agentId?: string;
@@ -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);
@@ -172,18 +172,41 @@ function createSession(
}
test("Codex usage reference follows CODEX_HOME and excludes custom base URLs", async () => {
const config = createConfig();
const client = new CodexAppServerAgentClient(createTestLogger(), {
env: { CODEX_HOME: "/accounts/second" },
});
expect(await client.resolveUsageReference({ config, session: null })).toEqual({
const session = new CodexAppServerAgentSession(
createConfig(),
null,
createTestLogger(),
() => {
throw new Error("unused");
},
{},
false,
false,
false,
undefined,
"interactive",
{ CODEX_HOME: "/accounts/second" },
);
expect(await session.getUsageReference()).toEqual({
source: "codex",
input: { codexHome: "/accounts/second" },
});
const custom = new CodexAppServerAgentClient(createTestLogger(), {
env: { CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" },
});
expect(await custom.resolveUsageReference({ config, session: null })).toBeNull();
const custom = new CodexAppServerAgentSession(
createConfig(),
null,
createTestLogger(),
() => {
throw new Error("unused");
},
{},
false,
false,
false,
undefined,
"interactive",
{ CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" },
);
expect(await custom.getUsageReference()).toBeNull();
});
function createProviderWithFakeAppServer(
@@ -3456,6 +3456,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly autoReviewEnabled: boolean = false,
private readonly agentId?: string,
private readonly initialResumePurpose: "interactive" | "history" = "interactive",
private readonly usageEnv: NodeJS.ProcessEnv = process.env,
) {
this.logger = logger.child({
module: "agent",
@@ -3485,6 +3486,17 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
async getUsageReference() {
if (this.usageEnv.OPENAI_BASE_URL) return null;
return {
source: "codex",
input: {
codexHome:
this.usageEnv.CODEX_HOME || path.join(this.usageEnv.HOME || os.homedir(), ".codex"),
},
};
}
get id(): string | null {
return this.currentThreadId;
}
@@ -7028,15 +7040,6 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly deps: CodexAppServerAgentDeps = {},
) {}
async resolveUsageReference() {
const env = buildCodexAppServerEnv(this.runtimeSettings);
if (env.OPENAI_BASE_URL) return null;
return {
source: "codex",
input: { codexHome: env.CODEX_HOME || path.join(env.HOME || os.homedir(), ".codex") },
};
}
private sessionDeps(launchEnv: Record<string, string> | undefined): CodexAppServerAgentDeps {
return {
...this.deps,
@@ -7158,6 +7161,7 @@ export class CodexAppServerAgentClient implements AgentClient {
autoReviewEnabled,
launchContext?.agentId,
"interactive",
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
);
await session.connect();
return session;
@@ -7190,6 +7194,7 @@ export class CodexAppServerAgentClient implements AgentClient {
autoReviewEnabled,
launchContext?.agentId,
options?.purpose ?? "interactive",
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
);
await session.connect();
return session;
@@ -194,20 +194,14 @@ describe("OMP agent client and session", () => {
expires: Date.now() + 60_000,
}),
);
db.prepare("INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?)").run(
"session:sticky:openai-codex:omp-session-1",
JSON.stringify({ type: "oauth", credentialId: 1 }),
Math.floor(Date.now() / 1000) + 100,
);
db.close();
try {
const env = {
const omp = new OmpHarness();
await omp.start({}, undefined, {
OMP_PROFILE: "",
PI_CODING_AGENT_DIR: agentDir,
XDG_DATA_HOME: "",
};
const omp = new OmpHarness({ env });
await omp.start({}, undefined, env);
});
const runtime = omp.runtime();
runtime.state = { ...runtime.state, model: { provider: "openai-codex", id: "codex-model" } };
const requestsBefore = runtime.getStateRequestCount;
@@ -222,20 +216,6 @@ describe("OMP agent client and session", () => {
input: { accessToken: "claude-fixture" },
});
expect(runtime.getStateRequestCount).toBe(requestsBefore + 2);
const beforeClosed = runtime.getStateRequestCount;
expect(
await omp.getStoredUsageReference("openai-codex/codex-model", "omp-session-1"),
).toEqual({
source: "codex",
input: { accessToken: "codex-fixture", accountId: "codex-account" },
});
expect(
await omp.getStoredUsageReference("openai-codex/codex-model", "omp-session-1", null),
).toEqual({
source: "codex",
input: { accessToken: "codex-fixture", accountId: "codex-account" },
});
expect(runtime.getStateRequestCount).toBe(beforeClosed);
await omp.close();
} finally {
rmSync(home, { recursive: true, force: true });
@@ -969,8 +969,9 @@ export class OmpAgentSession implements AgentSession {
private readonly paseoTools?: PaseoToolCatalog;
private readonly usageEnv: NodeJS.ProcessEnv;
getUsageEnvironment() {
return this.usageEnv;
async getUsageReference() {
const state = await this.runtimeSession.getState();
return resolveOmpUsageReference(state.sessionId, state.model?.provider ?? "", this.usageEnv);
}
get id(): string | null {
@@ -2273,27 +2274,6 @@ export class OmpAgentClient implements AgentClient {
options.runtime ?? createRuntime(options.logger, runtimeSettings, this.providerParams);
}
async resolveUsageReference({
config,
runtimeInfo,
persistence,
session,
}: {
config: AgentSessionConfig;
runtimeInfo: AgentRuntimeInfo | null;
persistence: AgentPersistenceHandle | null;
session: AgentSession | null;
}) {
const liveInfo = session ? await session.getRuntimeInfo() : null;
const model = liveInfo?.model ?? config.model ?? runtimeInfo?.model;
const sessionId = liveInfo?.sessionId ?? persistence?.sessionId;
const env =
session instanceof OmpAgentSession
? session.getUsageEnvironment()
: { ...process.env, ...this.runtimeSettings?.env };
return resolveOmpUsageReference(sessionId ?? "", model?.split("/")[0] ?? "", env);
}
private async configureNativePaseoTools(
runtimeSession: OmpRuntimeSession,
catalog: PaseoToolCatalog | undefined,
@@ -71,13 +71,11 @@ export class OmpHarness {
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
env?: Record<string, string>;
} = {},
) {
this.client = new OmpAgentClient({
logger: pino({ level: "silent" }),
runtime: this.omp,
runtimeSettings: options.env ? { env: options.env } : undefined,
providerIdleScheduler: options.providerIdleScheduler,
noTurnScheduler: options.noTurnScheduler,
usagePollScheduler: options.usagePollScheduler,
@@ -486,24 +484,7 @@ export class OmpHarness {
}
async getUsageReference() {
return this.client.resolveUsageReference({
config: { provider: "omp", cwd: CWD },
session: this.requireSession(),
});
}
async getStoredUsageReference(
model: string,
sessionId: string,
configuredModel: string | null = model,
) {
const input = {
config: { provider: "omp", cwd: CWD, model: configuredModel },
runtimeInfo: { provider: "omp", sessionId, model },
persistence: { provider: "omp", sessionId },
session: null,
};
return this.client.resolveUsageReference(input);
return this.requireSession().getUsageReference();
}
runningToolCallIds(): string[] {
@@ -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();
@@ -420,33 +350,19 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
},
{ serverManager: runtime, createClient: runtime.createClient },
);
expect(
await client.resolveUsageReference({
config: { ...buildConfig(cwd), model: "openai/gpt-5" },
session: null,
}),
).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
});
const session = await client.createSession(buildConfig(cwd));
await session.setModel?.("openai/gpt-5");
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
expect(await session.getUsageReference?.()).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
});
await session.setModel?.("opencode-go/qwen");
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
expect(await session.getUsageReference?.()).toEqual({
source: "opencode-go",
input: { apiKey: "go-key" },
});
await session.setModel?.("openai/gpt-5");
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
});
await session.setModel?.("other/model");
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toBeNull();
expect(await session.getUsageReference?.()).toBeNull();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const sessionId = (await session.getRuntimeInfo()).sessionId;
@@ -462,7 +378,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
},
} as OpenCodeEvent);
await vi.waitFor(async () =>
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
expect(await session.getUsageReference?.()).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
}),
@@ -470,58 +386,12 @@ 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 });
});
test("usage reference survives a model switch and default-model restart", async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(logger, {
env: {
OPENCODE_AUTH_CONTENT: JSON.stringify({
openai: { type: "oauth", access: "oauth-token", accountId: "acct-1" },
"opencode-go": { type: "api", key: "go-key" },
}),
},
});
try {
const runtimeInfo = {
provider: "opencode",
sessionId: "saved-session",
model: "openai/gpt-5",
};
const closed = {
config: { ...buildConfig(cwd), model: "openai/gpt-5" },
runtimeInfo,
persistence: null,
session: null,
};
expect(await client.resolveUsageReference(closed)).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
});
expect(
await client.resolveUsageReference({
...closed,
config: { ...buildConfig(cwd), model: null },
}),
).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("usage reference ignores another auth entry with a different type", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
@@ -541,7 +411,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
const session = await client.createSession(buildConfig(cwd));
try {
await session.setModel?.("opencode-go/qwen");
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
expect(await session.getUsageReference?.()).toEqual({
source: "opencode-go",
input: { apiKey: "go-key" },
});
@@ -1445,26 +1445,6 @@ export class OpenCodeAgentClient implements AgentClient {
this.resolveHomeDir = deps.resolveHomeDir ?? resolveOpenCodeHomeDir;
}
async resolveUsageReference({
config,
runtimeInfo,
session,
}: {
config: AgentSessionConfig;
runtimeInfo: AgentRuntimeInfo | null;
session: AgentSession | null;
}): Promise<UsageReference | null> {
const model = session
? (await session.getRuntimeInfo()).model
: (config.model ?? runtimeInfo?.model);
if (!model?.startsWith("openai/") && !model?.startsWith("opencode-go/")) return null;
const env =
session instanceof OpenCodeAgentSession
? session.getUsageEnvironment()
: { ...process.env, ...this.runtimeSettings?.env };
return resolveOpenCodeUsageReference(model, await readOpenCodeUsageAuth(env));
}
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
@@ -3636,41 +3616,23 @@ class OpenCodeAgentSession implements AgentSession {
sessionId: this.sessionId,
model: this.config.model ?? null,
modeId: this.currentMode,
thinkingOptionId: this.config.thinkingOptionId ?? null,
};
}
getUsageEnvironment(): NodeJS.ProcessEnv {
return this.usageEnv;
async getUsageReference(): Promise<UsageReference | null> {
const model = this.config.model;
if (!model?.startsWith("openai/") && !model?.startsWith("opencode-go/")) return null;
const auth = await readOpenCodeUsageAuth(this.usageEnv);
return resolveOpenCodeUsageReference(model, auth);
}
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> {
@@ -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" },
},
},
]);
});
@@ -229,16 +229,15 @@ test("Pi usage reference follows the current OAuth model and agent directory", a
runtime = sessionRuntime;
runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" };
});
const client = createClient(pi);
const session = await client.createSession(createConfig(), {
const session = await createClient(pi).createSession(createConfig(), {
env: { PI_CODING_AGENT_DIR: agentDir },
});
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
expect(await session.getUsageReference?.()).toEqual({
source: "codex",
input: { accessToken: "codex-token", accountId: "account-1" },
});
runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" };
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
expect(await session.getUsageReference?.()).toEqual({
source: "claude",
input: { accessToken: "claude-token" },
});
@@ -250,7 +249,7 @@ test("Pi usage reference follows the current OAuth model and agent directory", a
}),
);
runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" };
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
expect(await session.getUsageReference?.()).toEqual({
source: "codex",
input: { accessToken: "codex-token" },
});
@@ -259,65 +258,15 @@ test("Pi usage reference follows the current OAuth model and agent directory", a
path.join(agentDir, "auth.json"),
JSON.stringify({ anthropic: { type: "api_key", key: "api-key" } }),
);
expect(await client.resolveUsageReference({ config: createConfig(), session })).toBeNull();
expect(await session.getUsageReference?.()).toBeNull();
runtime.state.model = { provider: "other", id: "other", name: "Other" };
expect(await client.resolveUsageReference({ config: createConfig(), session })).toBeNull();
expect(await session.getUsageReference?.()).toBeNull();
await session.close();
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
});
test("Pi resolves a stored model without opening a session", async () => {
const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-stored-usage-"));
try {
writeFileSync(
path.join(agentDir, "auth.json"),
JSON.stringify({
anthropic: { type: "oauth", access: "claude-token", refresh: "refresh" },
"openai-codex": {
type: "oauth",
access: "codex-token",
accountId: "account-1",
refresh: "refresh",
},
}),
);
const client = new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: new FakePi(),
runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } },
});
expect(
await client.resolveUsageReference({
config: createConfig({ model: "anthropic/claude-sonnet-4" }),
session: null,
}),
).toEqual({ source: "claude", input: { accessToken: "claude-token" } });
const runtimeInfo = {
provider: "pi",
sessionId: "saved-session",
model: "openai-codex/gpt-5",
};
const closed = {
config: createConfig({ model: "openai-codex/gpt-5" }),
runtimeInfo,
persistence: null,
session: null,
};
expect(await client.resolveUsageReference(closed)).toEqual({
source: "codex",
input: { accessToken: "codex-token", accountId: "account-1" },
});
expect(await client.resolveUsageReference({ ...closed, config: createConfig() })).toEqual({
source: "codex",
input: { accessToken: "codex-token", accountId: "account-1" },
});
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
});
test("forwards launch-context env to the Pi process launch", async () => {
const pi = new FakePi();
const client = createClient(pi);
@@ -1141,39 +1141,37 @@ function createRuntime(
});
}
async function resolvePiUsageReference(provider: string | undefined, env?: Record<string, string>) {
let source: string | null = null;
if (provider === "openai-codex") source = "codex";
if (provider === "anthropic") source = "claude";
if (!source || !provider) return null;
try {
const auth: unknown = JSON.parse(
await readFile(join(resolvePiAgentDir(env), "auth.json"), "utf8"),
);
if (provider === "openai-codex") {
const credential = piCodexUsageAuthSchema.parse(auth)["openai-codex"];
if (!credential) return null;
return {
source,
input: {
accessToken: credential.access,
...(credential.accountId ? { accountId: credential.accountId } : {}),
},
};
}
const credential = piClaudeUsageAuthSchema.parse(auth).anthropic;
return credential ? { source, input: { accessToken: credential.access } } : null;
} catch {
return null;
}
}
export class PiRpcAgentSession implements AgentSession {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
getUsageEnvironment() {
return this.usageEnv;
async getUsageReference() {
await this.refreshState();
const provider = this.state.model?.provider;
let source: string | null = null;
if (provider === "openai-codex") source = "codex";
if (provider === "anthropic") source = "claude";
if (!source || !provider) return null;
try {
const auth: unknown = JSON.parse(
await readFile(join(resolvePiAgentDir(this.usageEnv), "auth.json"), "utf8"),
);
if (provider === "openai-codex") {
const credential = piCodexUsageAuthSchema.parse(auth)["openai-codex"];
if (!credential) return null;
return {
source,
input: {
accessToken: credential.access,
...(credential.accountId ? { accountId: credential.accountId } : {}),
},
};
}
const credential = piClaudeUsageAuthSchema.parse(auth).anthropic;
return credential ? { source, input: { accessToken: credential.access } } : null;
} catch {
return null;
}
}
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
@@ -2479,25 +2477,6 @@ export class PiRpcAgentClient implements AgentClient {
this.usagePollScheduler = options.usagePollScheduler;
}
async resolveUsageReference({
config,
runtimeInfo,
session,
}: {
config: AgentSessionConfig;
runtimeInfo: AgentRuntimeInfo | null;
session: AgentSession | null;
}) {
const model = session
? (await session.getRuntimeInfo()).model
: (config.model ?? runtimeInfo?.model);
const env =
session instanceof PiRpcAgentSession
? session.getUsageEnvironment()
: this.runtimeSettings?.env;
return resolvePiUsageReference(model?.split("/")[0], env);
}
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
@@ -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,13 +1,10 @@
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import { AgentStorage } from "../agent/agent-storage.js";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
import { ACPAgentSession } from "../agent/providers/acp-agent.js";
import type { UsageReference } from "../agent/agent-sdk-types.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { CodexAppServerAgentSession } from "../agent/providers/codex-app-server-agent.js";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -56,21 +53,75 @@ test("resolves a provider plugin session reference through its usage source", as
}
}, 60_000);
test("agent.resolve_usage_report resolves source IDs from provider clients", async () => {
test("agent.resolve_usage_report resolves source IDs from default built-in and ACP sessions", async () => {
const directory = fileURLToPath(
new URL("./test-fixtures/session-usage-reference/", import.meta.url),
);
const providers = ["claude", "codex", "copilot", "cursor", "kimi", "generic-acp"] as const;
const logger = createTestLogger();
const references = new Map<string, UsageReference | null>();
const claude = await new ClaudeAgentClient({
logger,
resolveBinary: async () => "/test/claude/bin",
}).createSession(
{ provider: "claude", cwd: directory },
{
env: {
HOME: directory,
CLAUDE_CONFIG_DIR: "",
ANTHROPIC_BASE_URL: "",
ANTHROPIC_API_KEY: "",
ANTHROPIC_AUTH_TOKEN: "",
},
},
);
references.set("claude", (await claude.getUsageReference?.()) ?? null);
await claude.close();
const codex = new CodexAppServerAgentSession(
{ provider: "codex", cwd: directory },
null,
logger,
() => {
throw new Error("Codex runtime should not start");
},
{},
false,
false,
false,
undefined,
"interactive",
{ HOME: directory, OPENAI_BASE_URL: "" },
);
references.set("codex", await codex.getUsageReference());
for (const provider of providers.slice(2)) {
const session = new ACPAgentSession(
{ provider, cwd: directory },
{
provider,
logger,
defaultCommand: ["unused"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
references.set(provider, await session.getUsageReference());
}
const agentClients = Object.fromEntries(
providers.map((provider) => {
const client = createTestAgentClient(provider);
let input: Record<string, string> = {};
if (provider === "claude") input = { configDir: directory };
if (provider === "codex") input = { codexHome: directory };
client.resolveUsageReference = async () => ({
source: provider,
input,
});
const createSession = client.createSession.bind(client);
client.createSession = async (...args) => {
const session = await createSession(...args);
session.getUsageReference = async () => references.get(provider) ?? null;
return session;
};
return [provider, client];
}),
);
@@ -97,156 +148,3 @@ test("agent.resolve_usage_report resolves source IDs from provider clients", asy
await daemon.close();
}
}, 60_000);
test("stored Claude and Codex agents resolve without opening a provider session", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-closed-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-static-"));
const sessionsOpened = new Map<string, number>();
const agentClients = Object.fromEntries(
["claude", "codex"].map((provider) => {
const providerClient = createTestAgentClient(provider, {
beforeCreateSession: async () => {
sessionsOpened.set(provider, (sessionsOpened.get(provider) ?? 0) + 1);
},
});
providerClient.resolveUsageReference = async ({ session, runtimeInfo, persistence }) => {
expect(session).toBeNull();
expect(runtimeInfo?.sessionId).toBeTruthy();
expect(runtimeInfo?.model).toBe(`${provider}/saved-model`);
expect(persistence?.sessionId).toBe(runtimeInfo?.sessionId);
return { source: "fixture-session-usage", input: { account: "from-session" } };
};
return [provider, providerClient];
}),
);
const options = {
paseoHomeRoot: root,
staticDir,
cleanup: false,
pluginsEnabled: false,
builtinPlugins: fixtureBuiltins(),
agentClients,
};
let daemon = await createTestPaseoDaemon(options);
let client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
const ids: string[] = [];
for (const provider of ["claude", "codex"]) {
const agent = await client.createAgent({ provider, cwd: root });
ids.push(agent.id);
await client.sendMessage(agent.id, "Say OK.");
await client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "idle");
expect(sessionsOpened.get(provider)).toBe(1);
}
await client.close();
await daemon.close();
const storage = new AgentStorage(
path.join(root, ".paseo", "agents"),
pino({ level: "silent" }),
);
for (const [index, provider] of ["claude", "codex"].entries()) {
const record = await storage.get(ids[index]!);
expect(record).not.toBeNull();
await storage.upsert({
...record!,
runtimeInfo: { ...record!.runtimeInfo!, model: `${provider}/saved-model` },
});
}
daemon = await createTestPaseoDaemon(options);
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: {} });
for (const [index, provider] of ["claude", "codex"].entries()) {
const result = await client.resolveAgentUsageReport({ agentId: ids[index]! });
expect(result.reportId).toBe("fixture-session-usage:from-session");
expect(
(await client.listUsageReports({ reportIds: [result.reportId!] })).reports,
).toHaveLength(1);
expect(sessionsOpened.get(provider)).toBe(1);
}
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
await rm(root, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
}, 60_000);
test("stored default-model OpenCode and Pi agents resolve their saved runtime model", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-saved-model-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-static-"));
const piDir = path.join(root, "pi-agent");
await mkdir(piDir);
await writeFile(
path.join(piDir, "auth.json"),
JSON.stringify({
"openai-codex": {
type: "oauth",
access: "codex-fixture-token",
accountId: "fixture-account",
refresh: "fixture-refresh",
},
}),
);
const storage = new AgentStorage(path.join(root, ".paseo", "agents"), pino({ level: "silent" }));
const ids: string[] = [];
const now = new Date().toISOString();
for (const provider of ["opencode", "pi"]) {
const id = randomUUID();
ids.push(id);
await storage.upsert({
id,
provider,
cwd: root,
createdAt: now,
updatedAt: now,
labels: {},
lastStatus: "closed",
config: { model: null },
runtimeInfo: {
provider,
sessionId: `${provider}-saved-session`,
model: provider === "opencode" ? "openai/gpt-5" : "openai-codex/gpt-5",
},
persistence: { provider, sessionId: `${provider}-saved-session` },
});
}
const daemon = await createTestPaseoDaemon({
paseoHomeRoot: root,
staticDir,
cleanup: false,
agentClients: {
opencode: new OpenCodeAgentClient(pino({ level: "silent" }), {
env: {
OPENCODE_AUTH_CONTENT: JSON.stringify({
openai: { type: "oauth", access: "codex-fixture-token", accountId: "fixture-account" },
}),
},
}),
pi: new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtimeSettings: { env: { PI_CODING_AGENT_DIR: piDir } },
}),
},
builtinPlugins: new BuiltinPluginLoader(undefined, ["codex-usage-source"]),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
for (const [index, id] of ids.entries()) {
expect(
(await client.resolveAgentUsageReport({ agentId: id })).reportId,
["opencode", "pi"][index],
).not.toBeNull();
}
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
}, 60_000);
+1 -1
View File
@@ -1000,7 +1000,7 @@ export class Session {
this.usageSession = new UsageSession({
emit: (msg) => this.emit(msg),
listAgents: () => this.agentManager.listAgents(),
resolveAgentReference: (agentId) => this.agentManager.resolveUsageReference(agentId),
getAgent: (agentId) => this.agentManager.getAgent(agentId),
runtime: pluginRuntime,
logger: this.sessionLogger,
});
@@ -1,5 +1,6 @@
import pino from "pino";
import { expect, test } from "vitest";
import type { AgentSession } from "../../agent/agent-sdk-types.js";
import type { SessionOutboundMessage } from "../../messages.js";
import { UsageSession } from "./usage-session.js";
@@ -8,8 +9,9 @@ test("collects live references for usage reports and resolves an agent report",
const references: unknown[] = [];
const reference = { source: "fixture", input: { account: "one" } };
const agent = {
id: "one",
session: {},
session: {
getUsageReference: async () => reference,
} as AgentSession,
};
const entry = {
id: "fixture:one",
@@ -21,8 +23,8 @@ test("collects live references for usage reports and resolves an agent report",
};
const usage = new UsageSession({
emit: (message) => emitted.push(message),
listAgents: () => [agent, { id: "closed", session: null }],
resolveAgentReference: async (id) => ({ found: id === "one", reference }),
listAgents: () => [agent, { session: null }],
getAgent: (id) => (id === "one" ? agent : null),
runtime: {
async listUsageReports(options) {
references.push(options.references);
@@ -57,7 +59,7 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () =
const usage = new UsageSession({
emit: (message) => emitted.push(message),
listAgents: () => [],
resolveAgentReference: async () => ({ found: false, reference: null }),
getAgent: () => null,
runtime: {
async listUsageReports() {
return [];
@@ -83,7 +85,7 @@ test("unknown agent returns agent_not_found", async () => {
const usage = new UsageSession({
emit: (message) => emitted.push(message),
listAgents: () => [],
resolveAgentReference: async () => ({ found: false, reference: null }),
getAgent: () => null,
runtime: {
listUsageReports: async () => [],
resolveUsageReference: async () => null,
@@ -1,20 +1,16 @@
import type pino from "pino";
import type { ProviderUsage, UsageReportEntry } from "@getpaseo/protocol/messages";
import type { UsageReference } from "../../agent/agent-sdk-types.js";
import type { AgentSession, UsageReference } from "../../agent/agent-sdk-types.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
interface UsageAgent {
id: string;
session: object | null;
session: AgentSession | null;
}
export interface UsageSessionOptions {
emit(message: SessionOutboundMessage): void;
listAgents(): UsageAgent[];
resolveAgentReference(agentId: string): Promise<{
found: boolean;
reference: UsageReference | null;
}>;
getAgent(agentId: string): UsageAgent | null;
runtime?: {
listUsageReports(options: {
forceRefresh?: boolean;
@@ -41,12 +37,8 @@ export class UsageSession {
await Promise.all(
this.options
.listAgents()
.filter((agent) => agent.session !== null)
.map(async (agent) =>
this.options
.resolveAgentReference(agent.id)
.then((resolved) => resolved.reference)
.catch(() => null),
.map(
async (agent) => agent.session?.getUsageReference?.().catch(() => null) ?? null,
),
)
).filter((reference): reference is UsageReference => reference !== null)
@@ -70,12 +62,12 @@ export class UsageSession {
): Promise<void> {
try {
if (!this.options.runtime) throw new Error("Plugin runtime is unavailable");
const resolved = await this.options.resolveAgentReference(msg.agentId);
if (!resolved.found) {
const agent = this.options.getAgent(msg.agentId);
if (!agent) {
this.emitError(msg, new Error(`Agent not found: ${msg.agentId}`), "agent_not_found");
return;
}
const reference = resolved.reference;
const reference = (await agent.session?.getUsageReference?.()) ?? null;
const reportId = reference
? await this.options.runtime.resolveUsageReference(reference)
: null;
+2 -2
View File
@@ -611,8 +611,8 @@ function FaqSection() {
<a href={HOSTED_HUB_URL} className={LINK_CLASS}>
sign in to Hosted Hub
</a>{" "}
for a free account. When you want more agent runs or more seats, upgrade from Billing
inside Hub.
for a free account. It needs no card and does not expire. When you want more agent runs or
more seats, upgrade from Billing inside Hub.
</FAQItem>
</div>
</section>

Some files were not shown because too many files have changed in this diff Show More