mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 13:52:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78cc05516a | ||
|
|
4e21643e3a |
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -462,7 +462,7 @@ export function WorkspaceSetupDialog() {
|
||||
cwd={sourceDirectory}
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraft={composerState?.commandDraft}
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
inputWrapperStyle={styles.composerInputWrapper}
|
||||
/>
|
||||
|
||||
@@ -198,15 +198,12 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
|
||||
expect(getLatest().composerState?.commandDraft).toEqual({
|
||||
status: "ready",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
},
|
||||
expect(getLatest().composerState?.commandDraftConfig).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
const hydratedTextReplacement = getLatest().textReplacement;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasDraftContent, resolveDraftKey } from "./input-draft-core";
|
||||
import {
|
||||
buildDraftCommandTarget,
|
||||
buildDraftCommandConfig,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
@@ -124,55 +124,46 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDraftCommandTarget", () => {
|
||||
const codexSelection = {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
thinkingOptionId: "high",
|
||||
availableModels: [],
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
};
|
||||
|
||||
it("needs a project when cwd is empty", () => {
|
||||
describe("buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
expect(
|
||||
buildDraftCommandTarget({
|
||||
selection: codexSelection,
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
modeOptions: [],
|
||||
},
|
||||
cwd: " ",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toEqual({ status: "needs-project" });
|
||||
});
|
||||
|
||||
it("needs a provider when none is selected", () => {
|
||||
expect(
|
||||
buildDraftCommandTarget({
|
||||
selection: { ...codexSelection, provider: null },
|
||||
cwd: "/repo",
|
||||
effectiveModelId: "",
|
||||
effectiveThinkingOptionId: "",
|
||||
}),
|
||||
).toEqual({ status: "needs-provider" });
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds the draft command config from derived composer state", () => {
|
||||
expect(
|
||||
buildDraftCommandTarget({
|
||||
selection: codexSelection,
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
thinkingOptionId: "high",
|
||||
availableModels: [],
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
},
|
||||
cwd: "/repo",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
status: "ready",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
},
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { TextReplacement } from "@/composer/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import {
|
||||
useAgentFormState,
|
||||
type CreateAgentInitialValues,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type DraftKeyInput,
|
||||
} from "@/composer/draft/input-draft-core";
|
||||
import {
|
||||
buildDraftCommandTarget,
|
||||
buildDraftCommandConfig,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type ProviderSelectionState,
|
||||
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
agentControls: DraftAgentControlsProps;
|
||||
commandDraft: DraftCommandTarget;
|
||||
commandDraftConfig: DraftCommandConfig | undefined;
|
||||
};
|
||||
|
||||
export interface AgentInputDraft {
|
||||
@@ -287,16 +287,19 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[applyProfileFeatureValues, formState],
|
||||
);
|
||||
|
||||
const commandDraft = useMemo(
|
||||
const commandDraftConfig = useMemo(
|
||||
() =>
|
||||
buildDraftCommandTarget({
|
||||
selection: providerSelection,
|
||||
cwd: workingDir,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
}),
|
||||
composerOptions
|
||||
? buildDraftCommandConfig({
|
||||
selection: providerSelection,
|
||||
cwd: workingDir,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
})
|
||||
: undefined,
|
||||
[
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatureValues,
|
||||
@@ -322,10 +325,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
onSetFeature: setDraftFeatureValue,
|
||||
onApplyAgentProfile: applyDraftAgentProfile,
|
||||
}),
|
||||
commandDraft,
|
||||
commandDraftConfig,
|
||||
};
|
||||
}, [
|
||||
commandDraft,
|
||||
commandDraftConfig,
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
@@ -355,6 +358,7 @@ export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftCommandTarget,
|
||||
buildDraftCommandConfig,
|
||||
buildDraftComposerCommandConfig: buildDraftCommandConfig,
|
||||
buildDraftAgentControls,
|
||||
};
|
||||
|
||||
@@ -682,7 +682,7 @@ export function WorkspaceDraftAgentTab({
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraft={composerState.commandDraft}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
|
||||
@@ -62,7 +62,7 @@ import {
|
||||
} from "./input/input";
|
||||
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import {
|
||||
@@ -973,8 +973,8 @@ interface ComposerProps {
|
||||
autoFocusKey?: string;
|
||||
/** Callback to expose a focus function to parent components (desktop only). */
|
||||
onFocusInput?: (focus: () => void) => void;
|
||||
/** Draft context for listing commands before an agent exists. Omitted for running agents. */
|
||||
commandDraft?: DraftCommandTarget;
|
||||
/** Optional draft context for listing commands before an agent exists. */
|
||||
commandDraftConfig?: DraftCommandConfig;
|
||||
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
||||
onMessageSent?: () => void;
|
||||
onComposerHeightChange?: (height: number) => void;
|
||||
@@ -1264,7 +1264,7 @@ function ComposerContentImpl({
|
||||
autoFocus = false,
|
||||
autoFocusKey,
|
||||
onFocusInput,
|
||||
commandDraft,
|
||||
commandDraftConfig,
|
||||
onMessageSent,
|
||||
onComposerHeightChange,
|
||||
onAttentionInputFocus,
|
||||
@@ -2346,7 +2346,7 @@ function ComposerContentImpl({
|
||||
setUserInput: replaceUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
draft: commandDraft,
|
||||
draftConfig: commandDraftConfig,
|
||||
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
|
||||
onClientSlashCommand: runClientSlashCommand,
|
||||
pluginClientSlashCommands,
|
||||
@@ -2355,7 +2355,7 @@ function ComposerContentImpl({
|
||||
replaceUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
commandDraft,
|
||||
commandDraftConfig,
|
||||
buildOutgoingAttachments,
|
||||
attachments,
|
||||
runClientSlashCommand,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1522,8 +1522,6 @@ export const ar: TranslationResources = {
|
||||
noFiles: "لم يتم العثور على ملفات أو أدلة",
|
||||
noCommands: "لم يتم العثور على أي أوامر",
|
||||
failedToLoad: "فشل التحميل",
|
||||
chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر",
|
||||
chooseModelForCommands: "اختر نموذجًا لعرض الأوامر",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "تعذر تحميل السجل الأقدم",
|
||||
|
||||
@@ -1547,8 +1547,6 @@ export const en = {
|
||||
noFiles: "No files or directories found",
|
||||
noCommands: "No commands found",
|
||||
failedToLoad: "Failed to load",
|
||||
chooseProjectForCommands: "Choose a project to see commands",
|
||||
chooseModelForCommands: "Select a model to see commands",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Couldn't load older history",
|
||||
@@ -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:
|
||||
|
||||
@@ -1568,8 +1568,6 @@ export const es: TranslationResources = {
|
||||
noFiles: "No se encontraron archivos ni directorios",
|
||||
noCommands: "No se encontraron comandos",
|
||||
failedToLoad: "No se pudo cargar",
|
||||
chooseProjectForCommands: "Elige un proyecto para ver los comandos",
|
||||
chooseModelForCommands: "Selecciona un modelo para ver los comandos",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "No se pudo cargar el historial anterior",
|
||||
|
||||
@@ -1572,8 +1572,6 @@ export const fr: TranslationResources = {
|
||||
noFiles: "Aucun fichier ou répertoire trouvé",
|
||||
noCommands: "Aucune commande trouvée",
|
||||
failedToLoad: "Échec du chargement",
|
||||
chooseProjectForCommands: "Choisissez un projet pour voir les commandes",
|
||||
chooseModelForCommands: "Sélectionnez un modèle pour voir les commandes",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Impossible de charger l'ancien historique",
|
||||
|
||||
@@ -1539,8 +1539,6 @@ export const ja: TranslationResources = {
|
||||
noFiles: "ファイルまたはディレクトリが見つかりません",
|
||||
noCommands: "コマンドが見つかりません",
|
||||
failedToLoad: "読み込みに失敗しました",
|
||||
chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください",
|
||||
chooseModelForCommands: "コマンドを表示するにはモデルを選択してください",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "古い履歴を読み込めませんでした",
|
||||
|
||||
@@ -1532,8 +1532,6 @@ export const ko: TranslationResources = {
|
||||
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
|
||||
noCommands: "명령을 찾을 수 없습니다",
|
||||
failedToLoad: "불러오지 못했습니다",
|
||||
chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요",
|
||||
chooseModelForCommands: "명령을 보려면 모델을 선택하세요",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "이전 기록을 불러올 수 없습니다",
|
||||
|
||||
@@ -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: "插件是受信任的代码",
|
||||
|
||||
@@ -1553,8 +1553,6 @@ export const ptBR: TranslationResources = {
|
||||
noFiles: "Nenhum arquivo ou diretório encontrado",
|
||||
noCommands: "Nenhum comando encontrado",
|
||||
failedToLoad: "Falha ao carregar",
|
||||
chooseProjectForCommands: "Escolha um projeto para ver os comandos",
|
||||
chooseModelForCommands: "Selecione um modelo para ver os comandos",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Não foi possível carregar o histórico mais antigo",
|
||||
|
||||
@@ -1551,8 +1551,6 @@ export const ru: TranslationResources = {
|
||||
noFiles: "Файлы и каталоги не найдены",
|
||||
noCommands: "Команды не найдены",
|
||||
failedToLoad: "Не удалось загрузить",
|
||||
chooseProjectForCommands: "Выберите проект, чтобы увидеть команды",
|
||||
chooseModelForCommands: "Выберите модель, чтобы увидеть команды",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "Не удалось загрузить старую историю.",
|
||||
|
||||
@@ -1505,8 +1505,6 @@ export const zhCN: TranslationResources = {
|
||||
noFiles: "没有找到文件或目录",
|
||||
noCommands: "没有找到 commands",
|
||||
failedToLoad: "加载失败",
|
||||
chooseProjectForCommands: "选择项目以查看命令",
|
||||
chooseModelForCommands: "选择模型以查看命令",
|
||||
},
|
||||
loadOlderHistory: {
|
||||
failed: "无法加载更早历史",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -82,15 +82,10 @@ const EXPECTED_CLAUDE_MODELS = [
|
||||
model: "Opus 4.8",
|
||||
descriptionFragment: "Previous release",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-5-5",
|
||||
model: "Sonnet 5.5",
|
||||
descriptionFragment: "Best for everyday tasks",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-5",
|
||||
model: "Sonnet 5",
|
||||
descriptionFragment: "Previous release",
|
||||
descriptionFragment: "Best for everyday tasks",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-7[1m]",
|
||||
@@ -435,7 +430,7 @@ try {
|
||||
"--quiet should print the same ordered model IDs returned by --json",
|
||||
);
|
||||
assert(
|
||||
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5-5"),
|
||||
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"),
|
||||
"captured --json output should include the current Claude everyday model id",
|
||||
);
|
||||
console.log("✓ provider models --quiet outputs model IDs only\n");
|
||||
|
||||
@@ -58,23 +58,10 @@ export async function runFollowUp(url: string, agentId: string): Promise<string
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.agents.ref(agentId).run("Summarize your progress and next step.", {
|
||||
activeTurnBehavior: "interrupt",
|
||||
});
|
||||
const result = await client.agents.ref(agentId).run("Summarize your progress and next step.");
|
||||
if (result.status !== "idle") throw new Error(result.error ?? result.status);
|
||||
return result.lastMessage;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function steerAgent(url: string, agentId: string, prompt: string): Promise<void> {
|
||||
const client = createClient(url);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.agents.ref(agentId).send(prompt, { activeTurnBehavior: "steer" });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6906,70 +6906,6 @@ test("creation reconnect observation uses connection-owned subscriptions and rel
|
||||
expect(phases).toEqual(["accepted", "failed"]);
|
||||
});
|
||||
|
||||
test("uploadFile started while connecting waits for the connection and uploads", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "upload-while-connecting",
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
suppressSendErrors: true,
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const connection = client.connect();
|
||||
expect(client.getConnectionState().status).toBe("connecting");
|
||||
|
||||
const upload = client.uploadFile({
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
bytes: new TextEncoder().encode("hello world"),
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
requestId: "req-upload-connecting",
|
||||
chunkSize: 5,
|
||||
});
|
||||
void upload.catch(() => undefined);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
mock.triggerOpen();
|
||||
await connection;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const frames = mock.sent
|
||||
.filter((frame) => typeof frame !== "string")
|
||||
.map(assertUint8Array)
|
||||
.map(decodeFileTransferFrame);
|
||||
expect(frames.map((frame) => frame.opcode)).toEqual([
|
||||
FileTransferOpcode.FileBegin,
|
||||
FileTransferOpcode.FileChunk,
|
||||
FileTransferOpcode.FileChunk,
|
||||
FileTransferOpcode.FileChunk,
|
||||
FileTransferOpcode.FileEnd,
|
||||
]);
|
||||
});
|
||||
const requestIndex = mock.sent.findIndex(
|
||||
(frame) =>
|
||||
typeof frame === "string" && parseSentFrame(frame).requestId === "req-upload-connecting",
|
||||
);
|
||||
expect(parseSentFrame(mock.sent[requestIndex])).toMatchObject({ type: "file.upload.request" });
|
||||
expect(requestIndex).toBeLessThan(mock.sent.findIndex((frame) => typeof frame !== "string"));
|
||||
|
||||
const file = {
|
||||
type: "uploaded_file" as const,
|
||||
id: "upload_req-upload-connecting",
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
path: "/tmp/paseo-uploads/upload_req-upload-connecting/notes.txt",
|
||||
};
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "file.upload.response",
|
||||
payload: { requestId: "req-upload-connecting", file, error: null },
|
||||
}),
|
||||
);
|
||||
await expect(upload).resolves.toEqual({ requestId: "req-upload-connecting", file, error: null });
|
||||
});
|
||||
|
||||
test("uploadFile stops sending chunks when the connection closes between sends", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
|
||||
@@ -425,7 +425,6 @@ export interface DaemonClientTrace {
|
||||
|
||||
export interface SendMessageOptions {
|
||||
messageId?: string;
|
||||
/** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */
|
||||
activeTurnBehavior?: ActiveTurnBehavior;
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
attachments?: SendAgentMessageRequest["attachments"];
|
||||
@@ -1170,7 +1169,7 @@ function toReasonCode(reason: string | null | undefined): string | null {
|
||||
}
|
||||
|
||||
interface PendingSend {
|
||||
send: () => void;
|
||||
message: SessionInboundMessage;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle: ReturnType<typeof setTimeout>;
|
||||
@@ -1798,23 +1797,12 @@ export class DaemonClient {
|
||||
* This prevents waiters from hanging forever when called during connection.
|
||||
*/
|
||||
private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise<void> {
|
||||
return this.sendWhenConnected(() => {
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves once connected, waiting out a connection that is still being established. */
|
||||
private whenConnected(): Promise<void> {
|
||||
return this.sendWhenConnected(() => undefined);
|
||||
}
|
||||
|
||||
private sendWhenConnected(send: () => void): Promise<void> {
|
||||
const status = this.connectionState.status;
|
||||
|
||||
// If connected, send immediately
|
||||
if (this.transport && status === "connected") {
|
||||
send();
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -1835,7 +1823,7 @@ export class DaemonClient {
|
||||
);
|
||||
}, DEFAULT_SEND_QUEUE_TIMEOUT_MS);
|
||||
|
||||
this.pendingSendQueue.push({ send, resolve, reject, timeoutHandle });
|
||||
this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1854,7 +1842,8 @@ export class DaemonClient {
|
||||
clearTimeout(pending.timeoutHandle);
|
||||
try {
|
||||
if (this.transport && this.connectionState.status === "connected") {
|
||||
pending.send();
|
||||
const payload = SessionInboundMessageSchema.parse(pending.message);
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
pending.resolve();
|
||||
} else {
|
||||
pending.reject(new DaemonConnectionError("Connection lost before message could be sent"));
|
||||
@@ -4857,8 +4846,6 @@ export class DaemonClient {
|
||||
if (!bytes) {
|
||||
throw new Error("File bytes are required.");
|
||||
}
|
||||
// The file frames bypass the send queue, so start only on an open connection.
|
||||
await this.whenConnected();
|
||||
const uploadTransport = this.transport;
|
||||
const resolvedRequestId = this.createRequestId(input.requestId);
|
||||
const modifiedAt = input.modifiedAt ?? new Date().toISOString();
|
||||
|
||||
@@ -744,17 +744,13 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
|
||||
expect(updatedAgents).toEqual(["Updated"]);
|
||||
expect(agent.current()).toEqual(updatedAgent);
|
||||
|
||||
const sendPromise = agent.send("hello", {
|
||||
messageId: "message-sdk",
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
const sendPromise = agent.send("hello", { messageId: "message-sdk" });
|
||||
const sendRequest = parseSentSessionMessage(ws.sent.at(-1));
|
||||
expect(sendRequest).toMatchObject({
|
||||
type: "send_agent_message_request",
|
||||
agentId: "agent_sdk",
|
||||
text: "hello",
|
||||
messageId: "message-sdk",
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
|
||||
ws.message(
|
||||
@@ -772,7 +768,6 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
|
||||
|
||||
const runPromise = agent.run("finish the task", {
|
||||
messageId: "run-message-sdk",
|
||||
activeTurnBehavior: "interrupt",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const runSendRequest = parseSentSessionMessage(ws.sent.at(-1));
|
||||
@@ -781,7 +776,6 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
|
||||
agentId: "agent_sdk",
|
||||
text: "finish the task",
|
||||
messageId: "run-message-sdk",
|
||||
activeTurnBehavior: "interrupt",
|
||||
});
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
ProjectPlacementPayload,
|
||||
WorkspaceProjectDescriptorPayload,
|
||||
RefreshProvidersSnapshotResponseMessage,
|
||||
SendAgentMessageRequest,
|
||||
SessionOutboundMessage,
|
||||
WorkspaceDescriptorPayload,
|
||||
WorkspaceCreateRequest,
|
||||
@@ -54,7 +55,6 @@ import type {
|
||||
FetchAgentTimelineDirection,
|
||||
FetchAgentTimelinePayload,
|
||||
FetchAgentTimelineProjection,
|
||||
SendMessageOptions,
|
||||
WaitForFinishResult,
|
||||
} from "./daemon-client.js";
|
||||
|
||||
@@ -276,7 +276,11 @@ export interface PaseoAgentTimelineRefetchOptions {
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export type PaseoAgentSendOptions = SendMessageOptions;
|
||||
export interface PaseoAgentSendOptions {
|
||||
messageId?: string;
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
attachments?: SendAgentMessageRequest["attachments"];
|
||||
}
|
||||
|
||||
export interface PaseoAgentRunOptions extends PaseoAgentSendOptions {
|
||||
timeoutMs?: number;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const skill = readFileSync(path.join(repository, "skills/paseo-plugin/SKILL.md"), "utf8");
|
||||
|
||||
function sectionCode(heading: string): string {
|
||||
const section = skill.slice(skill.indexOf(`\n## ${heading}\n`));
|
||||
const code = /```tsx\n([\s\S]*?)\n```/.exec(section)?.[1];
|
||||
if (!code) throw new Error(`No tsx example under "${heading}"`);
|
||||
return code;
|
||||
}
|
||||
|
||||
// Checks the example as a plugin client file, with the same options as the plugin examples.
|
||||
function typeErrors(code: string): string[] {
|
||||
const configPath = path.join(repository, "packages/plugin/tsconfig.examples.json");
|
||||
const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
|
||||
const { options } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath));
|
||||
const file = path.join(repository, "plugin-examples/skill-example/client/example.tsx");
|
||||
const host = ts.createCompilerHost(options);
|
||||
const getSourceFile = host.getSourceFile;
|
||||
host.getSourceFile = (name, language, ...rest) =>
|
||||
path.resolve(name) === file
|
||||
? ts.createSourceFile(name, code, language)
|
||||
: getSourceFile.call(host, name, language, ...rest);
|
||||
host.fileExists = (name) => path.resolve(name) === file || ts.sys.fileExists(name);
|
||||
const program = ts.createProgram([file], options, host);
|
||||
return ts
|
||||
.getPreEmitDiagnostics(program)
|
||||
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
|
||||
}
|
||||
|
||||
describe("paseo-plugin skill examples", () => {
|
||||
it("composer pill example typechecks against the plugin SDK", () => {
|
||||
expect(typeErrors(sectionCode("Add a composer pill"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -4851,53 +4851,6 @@ test("persists live mode, model, and thinking changes without an external snapsh
|
||||
expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4");
|
||||
});
|
||||
|
||||
test("model changes persist the resolved thinking and fresh provider handle before another turn", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-model-selection-"));
|
||||
class ModelSession extends TestAgentSession {
|
||||
selection = { model: "source", thinkingOptionId: "medium" as string | undefined };
|
||||
async setModel(model: string | null) {
|
||||
this.selection = { model: model ?? "default", thinkingOptionId: undefined };
|
||||
this.pushEvent({
|
||||
type: "thinking_option_changed",
|
||||
provider: "codex",
|
||||
thinkingOptionId: null,
|
||||
});
|
||||
}
|
||||
describePersistence() {
|
||||
return { provider: "codex" as const, sessionId: this.id, metadata: { ...this.selection } };
|
||||
}
|
||||
}
|
||||
class ModelClient extends TestAgentClient {
|
||||
async createSession(config: AgentSessionConfig) {
|
||||
return new ModelSession(config);
|
||||
}
|
||||
}
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new ModelClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, model: "source", thinkingOptionId: "medium" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
await manager.setAgentModel(agent.id, "target");
|
||||
await manager.flush();
|
||||
const stored = await storage.get(agent.id);
|
||||
expect(stored?.config?.model).toBe("target");
|
||||
expect(stored?.config?.thinkingOptionId).toBeUndefined();
|
||||
expect(stored?.persistence?.metadata?.model).toBe("target");
|
||||
expect(stored?.persistence?.metadata?.thinkingOptionId).toBeUndefined();
|
||||
} finally {
|
||||
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("later explicit config mutations win over events emitted by earlier mutations", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-config-mutation-order-"));
|
||||
class ConfigMutationSession extends TestAgentSession {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -489,28 +490,13 @@ function wrapClientProvider(
|
||||
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 +506,8 @@ function wrapClientProvider(
|
||||
),
|
||||
),
|
||||
resumeSession: async (handle, overrides, launchContext, options) =>
|
||||
wrapSession(
|
||||
wrapSessionProvider(
|
||||
provider,
|
||||
await inner.resumeSession(
|
||||
{
|
||||
...handle,
|
||||
@@ -582,7 +569,7 @@ function wrapClientProvider(
|
||||
}
|
||||
return {
|
||||
...imported,
|
||||
session: wrapSession(imported.session),
|
||||
session: wrapSessionProvider(provider, imported.session),
|
||||
config: {
|
||||
...imported.config,
|
||||
provider,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -618,4 +535,85 @@ describe("OpenCode v2 session lifecycle", () => {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("switching to a model without the current variant sends the next prompt without it", async () => {
|
||||
const harness = offeringModels({
|
||||
"opencode/space-bunny-free": ["low", "medium", "high"],
|
||||
"opencode/longcat-2.5-preview-free": [],
|
||||
"opencode/muse-spark-free": ["minimal", "medium"],
|
||||
});
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
model: "opencode/space-bunny-free",
|
||||
thinkingOptionId: "medium",
|
||||
});
|
||||
const thinkingChanges: Array<string | null> = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "thinking_option_changed") thinkingChanges.push(event.thinkingOptionId);
|
||||
});
|
||||
try {
|
||||
await session.setModel!("opencode/muse-spark-free");
|
||||
expect(await session.getRuntimeInfo!()).toMatchObject({ thinkingOptionId: "medium" });
|
||||
|
||||
await session.setModel!("opencode/longcat-2.5-preview-free");
|
||||
await session.run("hello");
|
||||
expect(harness.prompts).toEqual(["hello"]);
|
||||
expect(await session.getRuntimeInfo!()).toMatchObject({
|
||||
model: "opencode/longcat-2.5-preview-free",
|
||||
thinkingOptionId: null,
|
||||
});
|
||||
expect(thinkingChanges).toEqual([null]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Behaves like an OpenCode v2 server: switching to any listed model succeeds,
|
||||
// and a prompt fails when the session's variant is not one its model offers.
|
||||
function offeringModels(variantsByModel: Record<string, string[]>): V2Harness {
|
||||
const harness = new V2Harness();
|
||||
const catalog: ModelInfo[] = Object.entries(variantsByModel).map(([ref, variants]) => {
|
||||
const [providerID, id] = ref.split("/");
|
||||
return {
|
||||
id,
|
||||
modelID: id,
|
||||
providerID,
|
||||
name: id,
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: variants.map((variant) => ({ id: variant })),
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 1000, output: 1000 },
|
||||
};
|
||||
});
|
||||
harness.api.model.list = async (input) => ({
|
||||
location: { directory: input?.location?.directory ?? harness.info.location.directory },
|
||||
data: catalog,
|
||||
});
|
||||
harness.api.session.create = async (input = {}) => {
|
||||
harness.creates.push(input);
|
||||
harness.info.model = input.model;
|
||||
return harness.info;
|
||||
};
|
||||
harness.api.session.switchModel = async ({ model }) => {
|
||||
harness.info.model = model;
|
||||
};
|
||||
harness.prompt = async (input) => {
|
||||
const model = harness.info.model;
|
||||
const offered = catalog.find(
|
||||
(entry) => entry.providerID === model?.providerID && entry.id === model?.id,
|
||||
);
|
||||
if (model?.variant && !offered?.variants.some((variant) => variant.id === model.variant))
|
||||
throw new Error(`Variant unavailable for ${model.providerID}/${model.id}: ${model.variant}`);
|
||||
harness.prompts.push(input.text);
|
||||
};
|
||||
return harness;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SessionTurns } from "./turns.js";
|
||||
import { V2Timeline } from "./timeline.js";
|
||||
import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js";
|
||||
|
||||
import type { SessionInfo, SessionMessageInfo } from "@opencode/client";
|
||||
import type { ModelRef, SessionInfo, SessionMessageInfo } from "@opencode/client";
|
||||
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import type { Logger } from "pino";
|
||||
@@ -276,48 +276,36 @@ 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)
|
||||
: (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,
|
||||
// OpenCode rejects a prompt whose variant the model does not offer, so the
|
||||
// current variant only carries over to a model that has it.
|
||||
const variant = await this.offeredVariant(selected, this.config.thinkingOptionId);
|
||||
await this.client.session.switchModel({
|
||||
sessionID: this.id,
|
||||
model: { id: selected.id, providerID: selected.providerID, variant },
|
||||
});
|
||||
this.config.model = model ?? undefined;
|
||||
if (variant !== this.config.thinkingOptionId) {
|
||||
this.config.thinkingOptionId = variant;
|
||||
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: null });
|
||||
}
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
private async offeredVariant(model: ModelRef, variant: string | undefined) {
|
||||
if (!variant) return undefined;
|
||||
const { data } = await this.client.model.list({ location: { directory: this.config.cwd } });
|
||||
const offered = data.find(
|
||||
(entry) => entry.providerID === model.providerID && entry.id === model.id,
|
||||
);
|
||||
return offered?.variants.some((entry) => entry.id === variant) ? variant : undefined;
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -468,70 +468,34 @@ The callback receives the same context as the matching Command Center item plus
|
||||
A pill is a per-agent button in the composer track bar next to Tasks and Subagents. Add and remove pills from the client entry lifecycle. `addComposerPill` exists on `PluginClientContext`.
|
||||
|
||||
```tsx
|
||||
import type { PluginButtonRegistration, PluginClientContext } from "@getpaseo/plugin/client";
|
||||
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
const pills = new Map<string, PluginButtonRegistration>();
|
||||
const lifetime = new AbortController();
|
||||
const register = (agent: { id: string; workspaceId?: string | null }) => {
|
||||
if (lifetime.signal.aborted || !agent.workspaceId) return;
|
||||
const agentId = agent.id;
|
||||
const workspaceId = agent.workspaceId;
|
||||
pills.get(agentId)?.remove();
|
||||
const pills = new Map<string, () => void>();
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
|
||||
const { id: agentId, workspaceId } = update.agent;
|
||||
pills.get(agentId)?.();
|
||||
pills.set(
|
||||
agentId,
|
||||
client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open review",
|
||||
workspaceId,
|
||||
agentId,
|
||||
button: {
|
||||
title: "Open review",
|
||||
icon: "Scan",
|
||||
label: "Review",
|
||||
behavior: {
|
||||
kind: "action",
|
||||
onPress() {
|
||||
client.openPanel("review", { workspaceId, agentId });
|
||||
},
|
||||
},
|
||||
Component: ReviewPill,
|
||||
async onPress() {
|
||||
client.openPanel("review", { workspaceId, agentId });
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
const removeAll = () => {
|
||||
for (const pill of pills.values()) pill.remove();
|
||||
pills.clear();
|
||||
};
|
||||
// An owned list subscription delivers existing agents, then updates, and a fresh snapshot after a reconnect.
|
||||
void client.paseo.agents
|
||||
.list({ subscribe: {}, signal: lifetime.signal })
|
||||
.then(({ subscription }) => {
|
||||
subscription.subscribe({
|
||||
snapshot: ({ entries }) => {
|
||||
removeAll();
|
||||
for (const { agent } of entries) register(agent);
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "agent_update") return;
|
||||
const update = message.payload;
|
||||
if (update.kind === "upsert") return register(update.agent);
|
||||
pills.get(update.agentId)?.remove();
|
||||
pills.delete(update.agentId);
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!lifetime.signal.aborted) console.error("Agent observation failed", error);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
lifetime.abort();
|
||||
removeAll();
|
||||
unsubscribe();
|
||||
for (const remove of pills.values()) remove();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry. The `button` descriptor sets the icon, label, and behavior; Paseo owns the pressable, chrome, pending state, error reporting, and placement. `addComposerPill` returns a registration with `update()` and `remove()`. `remove()` is idempotent, and Paseo removes every pill when the plugin, client entrypoint, or host connection is torn down.
|
||||
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry. The component owns its icon and text; Paseo owns the pressable, chrome, pending state, error reporting, and placement. Removal functions are idempotent, and Paseo removes every pill when the plugin, client entrypoint, or host connection is torn down.
|
||||
|
||||
## Transform and render timeline items
|
||||
|
||||
|
||||
Reference in New Issue
Block a user