Compare commits

...
Author SHA1 Message Date
Mohamed Boudra a134873d47 fix(usage): resolve saved provider accounts without sessions
Default-model chats retain their effective model in saved runtime info, and the persistence handle retains the session ID needed for credential attribution. Pass both through the provider client so closed chats resolve usage after restart.
2026-09-29 13:47:57 +02:00
Mohamed Boudra 4274b2be0d fix(usage): show account reports in reopened chats
Stored agents have no loaded session after a daemon restart, so session-owned reference lookup returned null. Resolve through provider clients using stored config, and request the report when a chat opens even during a turn.
2026-09-29 13:17:31 +02:00
paseo-bot[bot] b9ac1d4982 fix(app): add a Back to plugins link to plugin settings screens on desktop (#5620)
On the wide settings layout, a plugin settings screen showed only its title
and the plugin's content, so getting back to the Plugins page meant finding
Plugins in the sidebar again. The compact layout already had a Back header
and project settings already had a desktop "Back to projects" link. Plugin
settings screens now render a "Back to plugins" link on desktop the same
way, including when the screen is offline or unavailable.

Closes #5518

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 09:10:21 +02:00
paseo-bot[bot] d91171954b fix(opencode): keep v2 edit text in completed edit details (#5609)
OpenCode v2's edit tool takes `path` instead of `filePath`, so the
OpenCode edit schema missed it and the shared fallback dropped the
camelCase oldString/newString. Completed edits reached the timeline as
{type: "edit", filePath} with nothing to diff.

Closes #5607

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 08:01:59 +02:00
paseo-bot[bot]andDaye f232d23e9a fix(client): let SDK callers choose what a send does to an active turn (#5605)
PaseoAgentSendOptions was a hand-copied subset of the daemon client's
SendMessageOptions and dropped activeTurnBehavior, so
agents.ref(id).send(text, { activeTurnBehavior: "steer" }) and run() failed
to typecheck even though the value is forwarded to the daemon. The public
type is now the options type send() passes through, so the two cannot
drift again.

Closes #4555

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Daye <2077152374@qq.com>
2026-09-29 06:53:14 +02:00
paseo-bot[bot] 68e722ec2e fix(app): request a chat outline only for a timeline the daemon has served (#5602)
A new agent's draft pane rendered the chat outline with its local tab or
draft id, so the web app asked the daemon for that id's prompt index before
the agent existed and the daemon logged Agent not found. The outline now
waits until the viewed timeline has an epoch, which only a daemon-served
agent timeline has.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 06:16:30 +02:00
paseo-bot[bot] 3b41183606 fix(app): start the context meter ring at twelve o'clock on web (#5598)
The ring's quarter-turn lived in a Unistyles style on the react-native-svg
root. On web, Unistyles only turns styles into classes for the React Native
components its Babel plugin rewrites, so the Svg received an empty style and
the arc started at three o'clock. The rotation now lives on the progress
circle as an SVG transform, which renders the same on web and native.

Closes #5596

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 04:57:36 +02:00
paseo-bot[bot]andIvan Malison 690f5298cb fix(app): keep draft composers off the agent command list before a project and model are chosen (#5594)
A draft composer passed no command config when it was missing a project
or a provider, which is what a running agent's composer passes. The `/`
menu then listed commands by the draft key, got "Agent not found", and
showed /clear and /exit. Drafts now always report a command target
(ready, needs-project, needs-provider), and an incomplete draft asks for
the missing choice instead of querying.

Closes #5516

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-09-29 04:08:31 +02:00
paseo-bot[bot] 68938b668e fix(skills): make the paseo-plugin composer pill example match the plugin SDK (#5591)
The example passed title, Component, and onPress straight to
addComposerPill and treated its return value as a remover function, so it
failed to typecheck against @getpaseo/plugin. It also followed agents with
agents.subscribe, a local listener that never delivers agents that exist
before the plugin loads. The example now uses the button descriptor and an
owned list subscription, like the plugin reference and local-plugin
example, and a test typechecks it against the SDK.

Closes #5517

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 02:51:54 +02:00
paseo-bot[bot] 560e478ef5 Wait for the connection before starting a file upload (#5585)
An upload started while the client was reconnecting, such as right after
returning from the Android file picker, queued its request but sent the
file frames at once. The frames were dropped and the upload failed with
"Connection changed during file upload". The upload now waits on the
same connection queue as other requests before it sends anything.

Closes #5512

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 01:05:46 +02:00
Mohamed Boudra 4965af219f docs(changelog): add 0.10.1 release notes 2026-09-29 00:03:24 +02:00
Mohamed Boudra 940dbfd242 fix(opencode): clear unsupported thinking when changing models (#5587)
Resolve retained variants against the destination model before applying a model change. Publish the effective thinking choice and refresh the provider resume handle immediately so a reload cannot restore the old selection.
2026-09-28 22:32:35 +01:00
Qi 4340607093 feat(claude): add Sonnet 5.5 with its model capabilities (#5583)
Claude Code 2.1.284 introduces Sonnet 5.5 with a native 1M context
window, medium default effort, and adaptive thinking that cannot be
turned off, so it takes the same shape as Opus 5.5 in the manifest.

Sonnet 5.5 rejects `thinking: {"type": "disabled"}` with a 400, so its
entry omits supportsThinkingDisabled and its thinking options have no
off. It also takes the Sonnet family tagline, which moves Sonnet 5 to
Previous release.

The catalog gates the entry on Claude Code 2.1.284, the version that
recognizes the model, so older installs keep their current catalog.
The existing major-minor runtime matching already resolves dashed
gateway IDs such as us.anthropic.claude-sonnet-5-5-20260928-v1:0 to
the new entry without collapsing them onto Sonnet 5.
2026-09-28 22:15:24 +01:00
paseo-bot[bot] 849a876bce Keep completed plugin-provider sessions error-free across a plugin reload (#5579)
Removing a plugin provider closed its runtime and published a runtime
failure for every open session, so an idle session whose turn had already
completed gained a "Provider connection closed" error. Only a turn that
is still in flight is interrupted now; an idle session goes stale and its
next prompt reopens it from persistence.

Closes #5535

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-28 22:55:07 +02:00
paseo-bot[bot] f4ba16a0b3 fix(app): keep line breaks in streamed replies when a chunk ends in indentation (#5577)
While a reply streams, presentation reparses only the growing last Markdown
block: the cached block text plus the new chunk. splitMarkdownBlocks drops a
trailing whitespace-only line, and only trailing "\n"s were added back, so a
reply that paused on "]\n   " cached the block as "]". The next chunk " B -->"
then continued the previous line, joining two lines of a code block. Codex
streams a line break and the next line's indent as separate deltas, so its
Mermaid diagrams hit this and failed to render. History reparses the whole
text, which is why a reload showed the reply correctly.

Keep every trailing blank line the split drops on the last block.

Closes #5456

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-28 22:09:16 +02:00
78 changed files with 1836 additions and 393 deletions
+18
View File
@@ -1,5 +1,23 @@
# 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,12 +17,14 @@ import {
hoverChatOutlinePrompt,
chatOutlineRail,
movePointerOffChatOutline,
observePromptIndexRequests,
pointAtChatOutlineRowEdge,
pressEnterOnFocusedPrompt,
splitCurrentPanelRight,
withStreamingMarkdownOutline,
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import { createCreationScenario } from "../support/helpers/creation";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
@@ -266,3 +268,21 @@ 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,6 +10,7 @@ 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 = [
{
@@ -338,6 +339,16 @@ 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) =>
@@ -359,6 +370,25 @@ 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();
@@ -0,0 +1,71 @@
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();
}
});
});
@@ -0,0 +1,78 @@
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,6 +14,7 @@ import {
expectConflictPreservesDraft,
discardTitle,
returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable,
} from "../support/helpers/plugin-settings";
@@ -43,6 +44,11 @@ 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,7 +1,11 @@
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 } from "../support/helpers/mock-agent";
import {
openAgentRoute,
seedMockAgentWorkspace,
seedRunningMockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
const REPORT_ID = "fixture:fixture-account";
@@ -42,6 +46,30 @@ 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,6 +3,7 @@ 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>,
@@ -229,3 +230,22 @@ 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,6 +206,12 @@ 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,6 +3,8 @@ 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>>;
@@ -105,6 +107,13 @@ 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,7 +12,6 @@ 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,11 +2,8 @@ import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/
export type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number];
export function shouldAcceptPromptIndexEpoch(
timelineEpoch: string | null,
indexEpoch: string,
): boolean {
return timelineEpoch === null || timelineEpoch === indexEpoch;
export function shouldAcceptPromptIndexEpoch(timelineEpoch: string, indexEpoch: string): boolean {
return timelineEpoch === indexEpoch;
}
/**
@@ -37,6 +37,31 @@ 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,8 +77,10 @@ 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) {
if (!isWeb || !enabled || timelineEpoch === null) {
setIndex(null);
return;
}
@@ -210,6 +210,17 @@ 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,8 +112,10 @@ export function createStreamPresentation() {
const index = prefix.length + offset;
let blockText = text;
if (offset === textBlocks.length - 1) {
const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? "";
blockText += trailingNewlines;
// 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 existing = previous?.[index];
const id = `${item.id}:block:${index}`;
@@ -117,7 +117,6 @@ export function ContextWindowMeter({
width={geometry.svgSize}
height={geometry.svgSize}
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
@@ -158,7 +157,6 @@ export function ContextWindowMeter({
width={svgSize}
height={svgSize}
viewBox={`0 0 ${svgSize} ${svgSize}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
@@ -180,6 +178,8 @@ 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,9 +226,6 @@ 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
commandDraftConfig={composerState?.commandDraftConfig}
commandDraft={composerState?.commandDraft}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
/>
@@ -198,12 +198,15 @@ describe("useAgentInputDraft live contract", () => {
});
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
expect(getLatest().composerState?.commandDraftConfig).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
expect(getLatest().composerState?.commandDraft).toEqual({
status: "ready",
config: {
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 {
buildDraftCommandConfig,
buildDraftCommandTarget,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
} from "@/provider-selection/provider-selection";
@@ -124,46 +124,55 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
});
});
describe("buildDraftComposerCommandConfig", () => {
it("returns undefined when cwd is empty", () => {
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", () => {
expect(
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "",
thinkingOptionId: "",
availableModels: [],
modeOptions: [],
},
buildDraftCommandTarget({
selection: codexSelection,
cwd: " ",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toBeUndefined();
).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" });
});
it("builds the draft command config from derived composer state", () => {
expect(
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "auto",
thinkingOptionId: "high",
availableModels: [],
modeOptions: [{ id: "auto", label: "Auto" }],
},
buildDraftCommandTarget({
selection: codexSelection,
cwd: "/repo",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
status: "ready",
config: {
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
},
});
});
});
+14 -18
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { UserComposerAttachment } from "@/attachments/types";
import type { TextReplacement } from "@/composer/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
import {
useAgentFormState,
type CreateAgentInitialValues,
@@ -16,7 +16,7 @@ import {
type DraftKeyInput,
} from "@/composer/draft/input-draft-core";
import {
buildDraftCommandConfig,
buildDraftCommandTarget,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
type ProviderSelectionState,
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
effectiveThinkingOptionId: string;
featureValues: Record<string, unknown> | undefined;
agentControls: DraftAgentControlsProps;
commandDraftConfig: DraftCommandConfig | undefined;
commandDraft: DraftCommandTarget;
};
export interface AgentInputDraft {
@@ -287,19 +287,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[applyProfileFeatureValues, formState],
);
const commandDraftConfig = useMemo(
const commandDraft = useMemo(
() =>
composerOptions
? buildDraftCommandConfig({
selection: providerSelection,
cwd: workingDir,
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
})
: undefined,
buildDraftCommandTarget({
selection: providerSelection,
cwd: workingDir,
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
}),
[
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
draftFeatureValues,
@@ -325,10 +322,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
onSetFeature: setDraftFeatureValue,
onApplyAgentProfile: applyDraftAgentProfile,
}),
commandDraftConfig,
commandDraft,
};
}, [
commandDraftConfig,
commandDraft,
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
@@ -358,7 +355,6 @@ export const __private__ = {
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
buildDraftCommandConfig,
buildDraftComposerCommandConfig: buildDraftCommandConfig,
buildDraftCommandTarget,
buildDraftAgentControls,
};
@@ -682,7 +682,7 @@ export function WorkspaceDraftAgentTab({
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
commandDraft={composerState.commandDraft}
agentControls={composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
+6 -6
View File
@@ -62,7 +62,7 @@ import {
} from "./input/input";
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } 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;
/** Optional draft context for listing commands before an agent exists. */
commandDraftConfig?: DraftCommandConfig;
/** Draft context for listing commands before an agent exists. Omitted for running agents. */
commandDraft?: DraftCommandTarget;
/** 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,
commandDraftConfig,
commandDraft,
onMessageSent,
onComposerHeightChange,
onAttentionInputFocus,
@@ -2346,7 +2346,7 @@ function ComposerContentImpl({
setUserInput: replaceUserInput,
serverId,
agentId,
draftConfig: commandDraftConfig,
draft: commandDraft,
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
onClientSlashCommand: runClientSlashCommand,
pluginClientSlashCommands,
@@ -2355,7 +2355,7 @@ function ComposerContentImpl({
replaceUserInput,
serverId,
agentId,
commandDraftConfig,
commandDraft,
buildOutgoingAttachments,
attachments,
runClientSlashCommand,
@@ -7,6 +7,7 @@ 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";
@@ -34,7 +35,7 @@ interface UseAgentAutocompleteInput {
setUserInput: (nextValue: string) => void;
serverId: string;
agentId: string;
draftConfig?: DraftCommandConfig;
draft?: DraftCommandTarget;
onAutocompleteApplied?: () => void;
onClientSlashCommand?: (command: ClientSlashCommand) => void;
canExecuteClientSlashCommand?: boolean;
@@ -117,18 +118,13 @@ type AvailableCommand =
| { source: "plugin"; command: PluginClientSlashCommand }
| { source: "provider"; command: AgentSlashCommand };
function normalizeDraftCommandConfig(
draftConfig?: DraftCommandConfig,
): DraftCommandConfig | undefined {
if (!draftConfig) {
function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig | undefined {
if (draft?.status !== "ready") {
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() ?? "";
@@ -337,6 +333,35 @@ 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 {
@@ -345,7 +370,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
setUserInput,
serverId,
agentId,
draftConfig,
draft,
onAutocompleteApplied,
onClientSlashCommand,
canExecuteClientSlashCommand,
@@ -380,13 +405,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
return () => clearTimeout(timer);
}, [fileFilterQuery]);
const normalizedDraftConfig = useMemo(
() => normalizeDraftCommandConfig(draftConfig),
[draftConfig],
const { isDraftContext, queryDraftConfig, canListCommands } = useMemo(
() => resolveDraftCommandContext(draft),
[draft],
);
const isDraftContext = normalizedDraftConfig !== undefined;
const queryDraftConfig = normalizedDraftConfig;
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
const agentCwd = useSessionStore(
@@ -410,17 +432,14 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
autocompleteCwd,
});
const {
commands,
isLoading: isCommandsLoading,
isError,
error,
} = useAgentCommandsQuery({
const commandsQuery = useAgentCommandsQuery({
serverId,
agentId,
enabled: mode === "command" && canLoadCommands,
enabled: mode === "command" && canLoadCommands && canListCommands,
draftConfig: queryDraftConfig,
});
const { commands, isError, error } = commandsQuery;
const isCommandsLoading = canListCommands && commandsQuery.isLoading;
const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading);
@@ -587,8 +606,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
mode === "file"
? t("agentAutocomplete.searchingWorkspace")
: t("agentAutocomplete.loadingCommands");
const emptyText =
mode === "file" ? t("agentAutocomplete.noFiles") : t("agentAutocomplete.noCommands");
const emptyText = resolveAutocompleteEmptyText({ mode, draft, t });
return {
isVisible,
@@ -17,6 +17,12 @@ 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;
+2
View File
@@ -1522,6 +1522,8 @@ export const ar: TranslationResources = {
noFiles: "لم يتم العثور على ملفات أو أدلة",
noCommands: "لم يتم العثور على أي أوامر",
failedToLoad: "فشل التحميل",
chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر",
chooseModelForCommands: "اختر نموذجًا لعرض الأوامر",
},
loadOlderHistory: {
failed: "تعذر تحميل السجل الأقدم",
+3
View File
@@ -1547,6 +1547,8 @@ 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",
@@ -2069,6 +2071,7 @@ export const en = {
offline: "Connect to this host to open plugin settings.",
update: "Update this host to use plugin settings.",
unavailable: "This plugin settings screen is unavailable.",
backToPlugins: "Back to plugins",
},
trustedTitle: "Plugins are trusted code",
trustedDescription:
+2
View File
@@ -1568,6 +1568,8 @@ export const es: TranslationResources = {
noFiles: "No se encontraron archivos ni directorios",
noCommands: "No se encontraron comandos",
failedToLoad: "No se pudo cargar",
chooseProjectForCommands: "Elige un proyecto para ver los comandos",
chooseModelForCommands: "Selecciona un modelo para ver los comandos",
},
loadOlderHistory: {
failed: "No se pudo cargar el historial anterior",
+2
View File
@@ -1572,6 +1572,8 @@ export const fr: TranslationResources = {
noFiles: "Aucun fichier ou répertoire trouvé",
noCommands: "Aucune commande trouvée",
failedToLoad: "Échec du chargement",
chooseProjectForCommands: "Choisissez un projet pour voir les commandes",
chooseModelForCommands: "Sélectionnez un modèle pour voir les commandes",
},
loadOlderHistory: {
failed: "Impossible de charger l'ancien historique",
+2
View File
@@ -1539,6 +1539,8 @@ export const ja: TranslationResources = {
noFiles: "ファイルまたはディレクトリが見つかりません",
noCommands: "コマンドが見つかりません",
failedToLoad: "読み込みに失敗しました",
chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください",
chooseModelForCommands: "コマンドを表示するにはモデルを選択してください",
},
loadOlderHistory: {
failed: "古い履歴を読み込めませんでした",
+2
View File
@@ -1532,6 +1532,8 @@ export const ko: TranslationResources = {
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
noCommands: "명령을 찾을 수 없습니다",
failedToLoad: "불러오지 못했습니다",
chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요",
chooseModelForCommands: "명령을 보려면 모델을 선택하세요",
},
loadOlderHistory: {
failed: "이전 기록을 불러올 수 없습니다",
@@ -18,6 +18,7 @@ export const pluginSettings = {
offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.",
update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.",
unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.",
backToPlugins: "العودة إلى الإضافات",
},
title: "الإضافات",
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
@@ -72,6 +73,7 @@ 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",
@@ -126,6 +128,7 @@ 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",
@@ -180,6 +183,7 @@ export const pluginSettings = {
offline: "このホストに接続してプラグイン設定を開いてください。",
update: "プラグイン設定を使用するには、このホストを更新してください。",
unavailable: "このプラグイン設定画面は利用できません。",
backToPlugins: "プラグインに戻る",
},
title: "プラグイン",
trustedTitle: "プラグインは信頼済みコードです",
@@ -234,6 +238,7 @@ export const pluginSettings = {
offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.",
update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.",
unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.",
backToPlugins: "플러그인으로 돌아가기",
},
title: "플러그인",
trustedTitle: "플러그인은 신뢰하는 코드입니다",
@@ -287,6 +292,7 @@ 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",
@@ -341,6 +347,7 @@ export const pluginSettings = {
offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.",
update: "Обновите этот хост, чтобы использовать настройки плагина.",
unavailable: "Этот экран настроек плагина недоступен.",
backToPlugins: "Вернуться к плагинам",
},
title: "Плагины",
trustedTitle: "Плагины — доверенный код",
@@ -395,6 +402,7 @@ export const pluginSettings = {
offline: "连接到此主机以打开插件设置。",
update: "更新此主机以使用插件设置。",
unavailable: "此插件设置页面不可用。",
backToPlugins: "返回插件",
},
title: "插件",
trustedTitle: "插件是受信任的代码",
+2
View File
@@ -1553,6 +1553,8 @@ export const ptBR: TranslationResources = {
noFiles: "Nenhum arquivo ou diretório encontrado",
noCommands: "Nenhum comando encontrado",
failedToLoad: "Falha ao carregar",
chooseProjectForCommands: "Escolha um projeto para ver os comandos",
chooseModelForCommands: "Selecione um modelo para ver os comandos",
},
loadOlderHistory: {
failed: "Não foi possível carregar o histórico mais antigo",
+2
View File
@@ -1551,6 +1551,8 @@ export const ru: TranslationResources = {
noFiles: "Файлы и каталоги не найдены",
noCommands: "Команды не найдены",
failedToLoad: "Не удалось загрузить",
chooseProjectForCommands: "Выберите проект, чтобы увидеть команды",
chooseModelForCommands: "Выберите модель, чтобы увидеть команды",
},
loadOlderHistory: {
failed: "Не удалось загрузить старую историю.",
+2
View File
@@ -1505,6 +1505,8 @@ export const zhCN: TranslationResources = {
noFiles: "没有找到文件或目录",
noCommands: "没有找到 commands",
failedToLoad: "加载失败",
chooseProjectForCommands: "选择项目以查看命令",
chooseModelForCommands: "选择模型以查看命令",
},
loadOlderHistory: {
failed: "无法加载更早历史",
+27 -3
View File
@@ -3,6 +3,7 @@ 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";
@@ -127,7 +128,30 @@ function SettingsContent({
}
const ThemedSettingsContent = withUnistyles(SettingsContent);
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function PluginSettingsContent(props: SettingsIdentity) {
return <ThemedSettingsContent {...props} uniProps={themeMapping} />;
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>
);
}
const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } }));
const styles = StyleSheet.create((theme) => ({
message: { color: theme.colors.foregroundMuted },
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
}));
@@ -5,7 +5,7 @@ import type {
ProviderSnapshotEntry,
} from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } 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,19 +286,22 @@ export function resolveEffectiveComposerThinkingOptionId(
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}
export function buildDraftCommandConfig(input: {
export function buildDraftCommandTarget(input: {
selection: ProviderSelectionState;
cwd: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined {
}): DraftCommandTarget {
const cwd = input.cwd.trim();
if (!input.selection.provider || !cwd) {
return undefined;
if (!cwd) {
return { status: "needs-project" };
}
if (!input.selection.provider) {
return { status: "needs-provider" };
}
return {
const config = {
provider: input.selection.provider,
cwd,
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
@@ -310,6 +313,7 @@ export function buildDraftCommandConfig(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}
commandDraftConfig={composerState?.commandDraftConfig}
commandDraft={composerState?.commandDraft}
agentControls={agentControlsWithDisabled}
/>
);
@@ -1333,6 +1333,8 @@ 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") {
+10 -6
View File
@@ -31,8 +31,13 @@ function usageReportsQueryKey(serverId: string) {
return ["usage", "reports", serverId] as const;
}
function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) {
return ["usage", "agent", serverId, agentId, model] as const;
function agentUsageQueryKey(
serverId: string,
agentId: string,
model: string | null,
isRunning: boolean,
) {
return ["usage", "agent", serverId, agentId, model, isRunning] as const;
}
function requireClient(serverId: string) {
@@ -160,8 +165,7 @@ 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 (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.
* turn completes (a new query key). The report itself is read from the shared cache.
*/
export function useAgentUsage(
serverId: string,
@@ -176,10 +180,10 @@ export function useAgentUsage(
}),
);
const identity = useFetchQuery({
queryKey: agentUsageQueryKey(serverId, agentId, model),
queryKey: agentUsageQueryKey(serverId, agentId, model, isRunning),
queryFn: async () =>
(await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId,
enabled: isConnected && isSupported && !isRunning,
enabled: isConnected && isSupported,
dataShape: "value",
staleTimeMs: 0,
});
+7 -2
View File
@@ -82,10 +82,15 @@ 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: "Best for everyday tasks",
descriptionFragment: "Previous release",
},
{
id: "claude-opus-4-7[1m]",
@@ -430,7 +435,7 @@ try {
"--quiet should print the same ordered model IDs returned by --json",
);
assert(
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"),
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5-5"),
"captured --json output should include the current Claude everyday model id",
);
console.log("✓ provider models --quiet outputs model IDs only\n");
@@ -58,10 +58,23 @@ 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.");
const result = await client.agents.ref(agentId).run("Summarize your progress and next step.", {
activeTurnBehavior: "interrupt",
});
if (result.status !== "idle") throw new Error(result.error ?? result.status);
return result.lastMessage;
} finally {
await client.close();
}
}
export async function steerAgent(url: string, agentId: string, prompt: string): Promise<void> {
const client = createClient(url);
try {
await client.connect();
await client.agents.ref(agentId).send(prompt, { activeTurnBehavior: "steer" });
} finally {
await client.close();
}
}
+64
View File
@@ -6906,6 +6906,70 @@ 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({
+19 -6
View File
@@ -425,6 +425,7 @@ 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"];
@@ -1169,7 +1170,7 @@ function toReasonCode(reason: string | null | undefined): string | null {
}
interface PendingSend {
message: SessionInboundMessage;
send: () => void;
resolve: () => void;
reject: (error: Error) => void;
timeoutHandle: ReturnType<typeof setTimeout>;
@@ -1797,12 +1798,23 @@ 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") {
const payload = SessionInboundMessageSchema.parse(message);
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
send();
return Promise.resolve();
}
@@ -1823,7 +1835,7 @@ export class DaemonClient {
);
}, DEFAULT_SEND_QUEUE_TIMEOUT_MS);
this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle });
this.pendingSendQueue.push({ send, resolve, reject, timeoutHandle });
});
}
@@ -1842,8 +1854,7 @@ export class DaemonClient {
clearTimeout(pending.timeoutHandle);
try {
if (this.transport && this.connectionState.status === "connected") {
const payload = SessionInboundMessageSchema.parse(pending.message);
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
pending.send();
pending.resolve();
} else {
pending.reject(new DaemonConnectionError("Connection lost before message could be sent"));
@@ -4846,6 +4857,8 @@ 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();
+7 -1
View File
@@ -744,13 +744,17 @@ 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" });
const sendPromise = agent.send("hello", {
messageId: "message-sdk",
activeTurnBehavior: "steer",
});
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(
@@ -768,6 +772,7 @@ 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));
@@ -776,6 +781,7 @@ 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({
+2 -6
View File
@@ -24,7 +24,6 @@ import type {
ProjectPlacementPayload,
WorkspaceProjectDescriptorPayload,
RefreshProvidersSnapshotResponseMessage,
SendAgentMessageRequest,
SessionOutboundMessage,
WorkspaceDescriptorPayload,
WorkspaceCreateRequest,
@@ -55,6 +54,7 @@ import type {
FetchAgentTimelineDirection,
FetchAgentTimelinePayload,
FetchAgentTimelineProjection,
SendMessageOptions,
WaitForFinishResult,
} from "./daemon-client.js";
@@ -276,11 +276,7 @@ export interface PaseoAgentTimelineRefetchOptions {
requestId?: string;
}
export interface PaseoAgentSendOptions {
messageId?: string;
images?: Array<{ data: string; mimeType: string }>;
attachments?: SendAgentMessageRequest["attachments"];
}
export type PaseoAgentSendOptions = SendMessageOptions;
export interface PaseoAgentRunOptions extends PaseoAgentSendOptions {
timeoutMs?: number;
@@ -0,0 +1,40 @@
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,6 +4851,53 @@ 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,6 +50,7 @@ import {
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type UsageReference,
type ImportedTimelineEntry,
type ImportableProviderSession,
type ListImportableSessionsOptions,
@@ -1167,6 +1168,24 @@ 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);
@@ -1943,6 +1962,7 @@ export class AgentManager {
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -673,8 +673,6 @@ 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>;
@@ -750,6 +748,13 @@ 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,7 +406,12 @@ describe("PluginAgentClientRegistry", () => {
cwd: "/workspace",
});
try {
expect(await session.getUsageReference?.()).toBeNull();
expect(
await client.resolveUsageReference?.({
config: { provider: harness.registration.id, cwd: "/workspace" },
session,
}),
).toBeNull();
expect(harness.inputs.some((input) => input.type === "session.usage_reference")).toBe(false);
} finally {
await session.close();
@@ -449,6 +454,63 @@ 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,6 +547,7 @@ 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 }> = [];
@@ -702,6 +703,7 @@ 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;
}
@@ -720,14 +722,16 @@ class ProviderRuntimeSession {
}
connectionClosed(error = new Error("Provider connection closed")): void {
if (!this.terminal) {
this.terminal = true;
// 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) {
this.publish({
type: "session.runtime_failed",
sessionId: this.id,
error: { message: error.message },
});
}
this.terminal = true;
this.rejectPending(error);
}
@@ -751,6 +755,10 @@ 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") {
@@ -889,6 +897,15 @@ 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,
@@ -1088,7 +1105,7 @@ class PluginAgentSession implements AgentSession {
return agentCapabilities(this.bridge.negotiatedCapabilities);
}
getUsageReference(): Promise<UsageReference | null> {
getPluginUsageReference(): Promise<UsageReference | null> {
return this.bridge.getUsageReference();
}
@@ -458,7 +458,6 @@ 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),
@@ -490,13 +489,28 @@ 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) =>
wrapSessionProvider(
provider,
wrapSession(
await inner.createSession(
{
...config,
@@ -506,8 +520,7 @@ function wrapClientProvider(
),
),
resumeSession: async (handle, overrides, launchContext, options) =>
wrapSessionProvider(
provider,
wrapSession(
await inner.resumeSession(
{
...handle,
@@ -569,7 +582,7 @@ function wrapClientProvider(
}
return {
...imported,
session: wrapSessionProvider(provider, imported.session),
session: wrapSession(imported.session),
config: {
...imported.config,
provider,
@@ -224,8 +224,17 @@ function createSessionWithConfig(
test("ACP usage reference uses the provider ID", async () => {
for (const provider of ["copilot", "cursor", "kimi", "custom-source"]) {
const session = createSessionWithConfig({ provider });
expect(await session.getUsageReference()).toEqual({ source: provider, input: {} });
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: {} });
}
});
@@ -882,6 +882,9 @@ 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;
@@ -1633,10 +1636,6 @@ 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.219",
resolveVersion: async () => "2.1.284",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
});
const { models } = await client.fetchCatalog({
@@ -516,6 +516,8 @@ 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");
@@ -529,16 +531,16 @@ describe("ClaudeAgentClient binary resolution", () => {
const logger = createTestLogger();
test("Claude usage reference follows CLAUDE_CONFIG_DIR and excludes API overrides", async () => {
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({
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({
source: "claude",
input: { configDir: "/accounts/second" },
});
await session.close();
});
test.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"])(
@@ -546,14 +548,15 @@ describe("ClaudeAgentClient binary resolution", () => {
async (name) => {
const client = new ClaudeAgentClient({
logger,
runtimeSettings: { env: { [name]: "override" } },
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession(
{ provider: "claude", cwd: process.cwd() },
{ env: { [name]: "override" } },
);
expect(await session.getUsageReference?.()).toBeNull();
await session.close();
expect(
await client.resolveUsageReference?.({
config: { provider: "claude", cwd: process.cwd() },
session: null,
}),
).toBeNull();
},
);
@@ -567,15 +570,16 @@ describe("ClaudeAgentClient binary resolution", () => {
},
},
});
const session = await registry["work-claude"].createClient(logger).createSession({
provider: "work-claude",
cwd: process.cwd(),
});
expect(await session.getUsageReference?.()).toEqual({
const client = registry["work-claude"].createClient(logger);
expect(
await client.resolveUsageReference?.({
config: { provider: "work-claude", cwd: process.cwd() },
session: null,
}),
).toEqual({
source: "claude",
input: { configDir: "/accounts/work" },
});
await session.close();
});
test("resolves the installed Claude Code version", async () => {
@@ -1289,6 +1293,26 @@ 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,6 +1519,15 @@ 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);
}
@@ -2042,15 +2051,6 @@ 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,10 +93,19 @@ 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 · Best for everyday tasks",
description: "Sonnet 5 · Previous release",
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.280"): ClaudeAgentClient {
function createCatalogClient(claudeCodeVersion = "2.1.284"): ClaudeAgentClient {
return new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => claudeCodeVersion,
@@ -57,6 +57,7 @@ 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]",
@@ -90,6 +91,7 @@ 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],
@@ -116,6 +118,9 @@ 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", () => {
@@ -178,6 +183,8 @@ 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"],
@@ -467,6 +474,16 @@ 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", () => {
@@ -600,6 +617,42 @@ 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();
@@ -610,6 +663,7 @@ 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,41 +172,18 @@ function createSession(
}
test("Codex usage reference follows CODEX_HOME and excludes custom base URLs", async () => {
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({
const config = createConfig();
const client = new CodexAppServerAgentClient(createTestLogger(), {
env: { CODEX_HOME: "/accounts/second" },
});
expect(await client.resolveUsageReference({ config, session: null })).toEqual({
source: "codex",
input: { codexHome: "/accounts/second" },
});
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();
const custom = new CodexAppServerAgentClient(createTestLogger(), {
env: { CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" },
});
expect(await custom.resolveUsageReference({ config, session: null })).toBeNull();
});
function createProviderWithFakeAppServer(
@@ -3456,7 +3456,6 @@ 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",
@@ -3486,17 +3485,6 @@ 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;
}
@@ -7040,6 +7028,15 @@ 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,
@@ -7161,7 +7158,6 @@ export class CodexAppServerAgentClient implements AgentClient {
autoReviewEnabled,
launchContext?.agentId,
"interactive",
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
);
await session.connect();
return session;
@@ -7194,7 +7190,6 @@ export class CodexAppServerAgentClient implements AgentClient {
autoReviewEnabled,
launchContext?.agentId,
options?.purpose ?? "interactive",
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
);
await session.connect();
return session;
@@ -194,14 +194,20 @@ 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 omp = new OmpHarness();
await omp.start({}, undefined, {
const env = {
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;
@@ -216,6 +222,20 @@ 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,9 +969,8 @@ export class OmpAgentSession implements AgentSession {
private readonly paseoTools?: PaseoToolCatalog;
private readonly usageEnv: NodeJS.ProcessEnv;
async getUsageReference() {
const state = await this.runtimeSession.getState();
return resolveOmpUsageReference(state.sessionId, state.model?.provider ?? "", this.usageEnv);
getUsageEnvironment() {
return this.usageEnv;
}
get id(): string | null {
@@ -2274,6 +2273,27 @@ 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,11 +71,13 @@ 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,
@@ -484,7 +486,24 @@ export class OmpHarness {
}
async getUsageReference() {
return this.requireSession().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);
}
runningToolCallIds(): string[] {
@@ -333,6 +333,76 @@ 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();
@@ -350,19 +420,33 @@ 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 session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
});
await session.setModel?.("opencode-go/qwen");
expect(await session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).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 session.getUsageReference?.()).toBeNull();
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toBeNull();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const sessionId = (await session.getRuntimeInfo()).sessionId;
@@ -378,7 +462,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
},
} as OpenCodeEvent);
await vi.waitFor(async () =>
expect(await session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
}),
@@ -386,12 +470,58 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
expect(events).toContainEqual({
type: "model_changed",
provider: "opencode",
runtimeInfo: { provider: "opencode", sessionId, model: "openai/gpt-5", modeId: null },
runtimeInfo: {
provider: "opencode",
sessionId,
model: "openai/gpt-5",
modeId: null,
thinkingOptionId: 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();
@@ -411,7 +541,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
const session = await client.createSession(buildConfig(cwd));
try {
await session.setModel?.("opencode-go/qwen");
expect(await session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
source: "opencode-go",
input: { apiKey: "go-key" },
});
@@ -1445,6 +1445,26 @@ 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,
@@ -3616,23 +3636,41 @@ class OpenCodeAgentSession implements AgentSession {
sessionId: this.sessionId,
model: this.config.model ?? null,
modeId: this.currentMode,
thinkingOptionId: this.config.thinkingOptionId ?? null,
};
}
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);
getUsageEnvironment(): NodeJS.ProcessEnv {
return this.usageEnv;
}
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,6 +221,15 @@ 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({
@@ -229,12 +238,12 @@ const OpencodeEditInputSchema = z.union([
newString: z.string().optional(),
})
.passthrough()
.transform((value) => ({
filePath: value.filePath,
oldString: nonEmptyString(value.oldString),
newString: nonEmptyString(value.newString),
unifiedDiff: undefined,
})),
.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 })),
ToolEditInputSchema,
]);
@@ -1,4 +1,4 @@
import type { SessionMessageInfo } from "@opencode/client";
import type { ModelInfo, SessionMessageInfo } from "@opencode/client";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
@@ -7,6 +7,89 @@ import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
describe("OpenCode v2 session lifecycle", () => {
test.each([
{ variants: ["medium", "custom"], selected: "medium", expected: "medium" },
{ variants: ["custom"], selected: "custom", expected: "custom" },
{ variants: ["high"], selected: "medium", expected: undefined },
{ variants: [], selected: "medium", expected: undefined },
{ variants: ["medium"], selected: "default", expected: undefined },
])(
"model switches retain only supported variants: $selected / $variants",
async ({ variants, selected, expected }) => {
const harness = new V2Harness();
const target: ModelInfo = {
id: "target",
modelID: "target",
providerID: "test",
name: "Target",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: variants.map((id) => ({ id })),
time: { released: 1 },
cost: [],
status: "active",
enabled: true,
limit: { context: 200000, output: 10000 },
};
harness.info.model = { providerID: "test", id: "source", variant: selected };
harness.api.model.list = async () => ({ location: harness.info.location, data: [target] });
harness.api.session.switchModel = async ({ model }) => {
harness.info.model = model;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
model: "test/source",
thinkingOptionId: selected,
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.setModel!("test/target");
expect(await session.getRuntimeInfo()).toMatchObject({
model: "test/target",
thinkingOptionId: expected ?? null,
});
expect(session.describePersistence().metadata?.thinkingOptionId).toBe(expected);
expect(events).toContainEqual({
type: "thinking_option_changed",
provider: "opencode",
thinkingOptionId: expected ?? null,
});
await expect(session.setModel!("test/missing")).rejects.toThrow(
"OpenCode model unavailable",
);
harness.api.session.switchModel = async () => {
throw new Error("Switch failed");
};
await expect(session.setModel!("test/target")).rejects.toThrow("Switch failed");
expect(session.describePersistence().metadata?.thinkingOptionId).toBe(expected);
expect(harness.info.model).toEqual({
providerID: "test",
id: "target",
...(expected ? { variant: expected } : {}),
});
harness.api.session.switchModel = async ({ model }) => {
harness.info.model = model;
};
harness.api.model.default = async () => ({ location: harness.info.location, data: target });
await session.setThinkingOption!("unsupported");
await session.setModel!(null);
expect((await session.getRuntimeInfo()).thinkingOptionId).toBeNull();
harness.api.model.list = async () => {
throw new Error("Catalog unavailable");
};
await expect(session.setModel!("test/source")).rejects.toThrow("Catalog unavailable");
expect((await session.getRuntimeInfo()).model).toBe("test/target");
} finally {
await session.close();
}
},
);
test("restores each same-directory agent environment on reconnect and resume", async () => {
const first = new V2Harness();
const second = new V2Harness();
@@ -276,25 +276,48 @@ export class OpenCodeV2Session implements AgentSession {
}
async setModel(model: string | null) {
await this.reconnectIfExited();
const selected = model
? modelRef(model, this.config.thinkingOptionId)
: (await this.client.model.default({ location: { directory: this.config.cwd } })).data;
const location = { directory: this.config.cwd };
const selected = model ? modelRef(model) : (await this.client.model.default({ location })).data;
if (!selected) throw new Error("OpenCode has no default model");
await this.client.session.switchModel({
sessionID: this.id,
model: {
id: selected.id,
providerID: selected.providerID,
variant: this.config.thinkingOptionId,
},
});
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,
});
this.emit({
type: "model_changed",
provider: "opencode",
runtimeInfo: await this.getRuntimeInfo(),
runtimeInfo: {
provider: "opencode",
sessionId: this.id,
model: `${selected.providerID}/${selected.id}`,
modeId: this.info.agent ?? null,
thinkingOptionId: variant ?? null,
},
});
}
async setThinkingOption(variant: string | null) {
await this.reconnectIfExited();
const model = this.info.model;
@@ -0,0 +1,56 @@
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,15 +229,16 @@ 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 session = await createClient(pi).createSession(createConfig(), {
const client = createClient(pi);
const session = await client.createSession(createConfig(), {
env: { PI_CODING_AGENT_DIR: agentDir },
});
expect(await session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
source: "codex",
input: { accessToken: "codex-token", accountId: "account-1" },
});
runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" };
expect(await session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
source: "claude",
input: { accessToken: "claude-token" },
});
@@ -249,7 +250,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 session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
source: "codex",
input: { accessToken: "codex-token" },
});
@@ -258,15 +259,65 @@ 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 session.getUsageReference?.()).toBeNull();
expect(await client.resolveUsageReference({ config: createConfig(), session })).toBeNull();
runtime.state.model = { provider: "other", id: "other", name: "Other" };
expect(await session.getUsageReference?.()).toBeNull();
expect(await client.resolveUsageReference({ config: createConfig(), session })).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,37 +1141,39 @@ 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;
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;
}
getUsageEnvironment() {
return this.usageEnv;
}
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
@@ -2477,6 +2479,25 @@ 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,6 +423,55 @@ 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,10 +1,13 @@
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 { 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 { AgentStorage } from "../agent/agent-storage.js";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { PiRpcAgentClient } from "../agent/providers/pi/agent.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";
@@ -53,75 +56,21 @@ test("resolves a provider plugin session reference through its usage source", as
}
}, 60_000);
test("agent.resolve_usage_report resolves source IDs from default built-in and ACP sessions", async () => {
test("agent.resolve_usage_report resolves source IDs from provider clients", 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);
const createSession = client.createSession.bind(client);
client.createSession = async (...args) => {
const session = await createSession(...args);
session.getUsageReference = async () => references.get(provider) ?? null;
return session;
};
let input: Record<string, string> = {};
if (provider === "claude") input = { configDir: directory };
if (provider === "codex") input = { codexHome: directory };
client.resolveUsageReference = async () => ({
source: provider,
input,
});
return [provider, client];
}),
);
@@ -148,3 +97,156 @@ test("agent.resolve_usage_report resolves source IDs from default built-in and A
await daemon.close();
}
}, 60_000);
test("stored Claude and Codex agents resolve without opening a provider session", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-closed-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-static-"));
const sessionsOpened = new Map<string, number>();
const agentClients = Object.fromEntries(
["claude", "codex"].map((provider) => {
const providerClient = createTestAgentClient(provider, {
beforeCreateSession: async () => {
sessionsOpened.set(provider, (sessionsOpened.get(provider) ?? 0) + 1);
},
});
providerClient.resolveUsageReference = async ({ session, runtimeInfo, persistence }) => {
expect(session).toBeNull();
expect(runtimeInfo?.sessionId).toBeTruthy();
expect(runtimeInfo?.model).toBe(`${provider}/saved-model`);
expect(persistence?.sessionId).toBe(runtimeInfo?.sessionId);
return { source: "fixture-session-usage", input: { account: "from-session" } };
};
return [provider, providerClient];
}),
);
const options = {
paseoHomeRoot: root,
staticDir,
cleanup: false,
pluginsEnabled: false,
builtinPlugins: fixtureBuiltins(),
agentClients,
};
let daemon = await createTestPaseoDaemon(options);
let client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
const ids: string[] = [];
for (const provider of ["claude", "codex"]) {
const agent = await client.createAgent({ provider, cwd: root });
ids.push(agent.id);
await client.sendMessage(agent.id, "Say OK.");
await client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "idle");
expect(sessionsOpened.get(provider)).toBe(1);
}
await client.close();
await daemon.close();
const storage = new AgentStorage(
path.join(root, ".paseo", "agents"),
pino({ level: "silent" }),
);
for (const [index, provider] of ["claude", "codex"].entries()) {
const record = await storage.get(ids[index]!);
expect(record).not.toBeNull();
await storage.upsert({
...record!,
runtimeInfo: { ...record!.runtimeInfo!, model: `${provider}/saved-model` },
});
}
daemon = await createTestPaseoDaemon(options);
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: {} });
for (const [index, provider] of ["claude", "codex"].entries()) {
const result = await client.resolveAgentUsageReport({ agentId: ids[index]! });
expect(result.reportId).toBe("fixture-session-usage:from-session");
expect(
(await client.listUsageReports({ reportIds: [result.reportId!] })).reports,
).toHaveLength(1);
expect(sessionsOpened.get(provider)).toBe(1);
}
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
await rm(root, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
}, 60_000);
test("stored default-model OpenCode and Pi agents resolve their saved runtime model", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-saved-model-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-static-"));
const piDir = path.join(root, "pi-agent");
await mkdir(piDir);
await writeFile(
path.join(piDir, "auth.json"),
JSON.stringify({
"openai-codex": {
type: "oauth",
access: "codex-fixture-token",
accountId: "fixture-account",
refresh: "fixture-refresh",
},
}),
);
const storage = new AgentStorage(path.join(root, ".paseo", "agents"), pino({ level: "silent" }));
const ids: string[] = [];
const now = new Date().toISOString();
for (const provider of ["opencode", "pi"]) {
const id = randomUUID();
ids.push(id);
await storage.upsert({
id,
provider,
cwd: root,
createdAt: now,
updatedAt: now,
labels: {},
lastStatus: "closed",
config: { model: null },
runtimeInfo: {
provider,
sessionId: `${provider}-saved-session`,
model: provider === "opencode" ? "openai/gpt-5" : "openai-codex/gpt-5",
},
persistence: { provider, sessionId: `${provider}-saved-session` },
});
}
const daemon = await createTestPaseoDaemon({
paseoHomeRoot: root,
staticDir,
cleanup: false,
agentClients: {
opencode: new OpenCodeAgentClient(pino({ level: "silent" }), {
env: {
OPENCODE_AUTH_CONTENT: JSON.stringify({
openai: { type: "oauth", access: "codex-fixture-token", accountId: "fixture-account" },
}),
},
}),
pi: new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtimeSettings: { env: { PI_CODING_AGENT_DIR: piDir } },
}),
},
builtinPlugins: new BuiltinPluginLoader(undefined, ["codex-usage-source"]),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
for (const [index, id] of ids.entries()) {
expect(
(await client.resolveAgentUsageReport({ agentId: id })).reportId,
["opencode", "pi"][index],
).not.toBeNull();
}
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
}, 60_000);
+1 -1
View File
@@ -1000,7 +1000,7 @@ export class Session {
this.usageSession = new UsageSession({
emit: (msg) => this.emit(msg),
listAgents: () => this.agentManager.listAgents(),
getAgent: (agentId) => this.agentManager.getAgent(agentId),
resolveAgentReference: (agentId) => this.agentManager.resolveUsageReference(agentId),
runtime: pluginRuntime,
logger: this.sessionLogger,
});
@@ -1,6 +1,5 @@
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";
@@ -9,9 +8,8 @@ test("collects live references for usage reports and resolves an agent report",
const references: unknown[] = [];
const reference = { source: "fixture", input: { account: "one" } };
const agent = {
session: {
getUsageReference: async () => reference,
} as AgentSession,
id: "one",
session: {},
};
const entry = {
id: "fixture:one",
@@ -23,8 +21,8 @@ test("collects live references for usage reports and resolves an agent report",
};
const usage = new UsageSession({
emit: (message) => emitted.push(message),
listAgents: () => [agent, { session: null }],
getAgent: (id) => (id === "one" ? agent : null),
listAgents: () => [agent, { id: "closed", session: null }],
resolveAgentReference: async (id) => ({ found: id === "one", reference }),
runtime: {
async listUsageReports(options) {
references.push(options.references);
@@ -59,7 +57,7 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () =
const usage = new UsageSession({
emit: (message) => emitted.push(message),
listAgents: () => [],
getAgent: () => null,
resolveAgentReference: async () => ({ found: false, reference: null }),
runtime: {
async listUsageReports() {
return [];
@@ -85,7 +83,7 @@ test("unknown agent returns agent_not_found", async () => {
const usage = new UsageSession({
emit: (message) => emitted.push(message),
listAgents: () => [],
getAgent: () => null,
resolveAgentReference: async () => ({ found: false, reference: null }),
runtime: {
listUsageReports: async () => [],
resolveUsageReference: async () => null,
@@ -1,16 +1,20 @@
import type pino from "pino";
import type { ProviderUsage, UsageReportEntry } from "@getpaseo/protocol/messages";
import type { AgentSession, UsageReference } from "../../agent/agent-sdk-types.js";
import type { UsageReference } from "../../agent/agent-sdk-types.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
interface UsageAgent {
session: AgentSession | null;
id: string;
session: object | null;
}
export interface UsageSessionOptions {
emit(message: SessionOutboundMessage): void;
listAgents(): UsageAgent[];
getAgent(agentId: string): UsageAgent | null;
resolveAgentReference(agentId: string): Promise<{
found: boolean;
reference: UsageReference | null;
}>;
runtime?: {
listUsageReports(options: {
forceRefresh?: boolean;
@@ -37,8 +41,12 @@ export class UsageSession {
await Promise.all(
this.options
.listAgents()
.map(
async (agent) => agent.session?.getUsageReference?.().catch(() => null) ?? null,
.filter((agent) => agent.session !== null)
.map(async (agent) =>
this.options
.resolveAgentReference(agent.id)
.then((resolved) => resolved.reference)
.catch(() => null),
),
)
).filter((reference): reference is UsageReference => reference !== null)
@@ -62,12 +70,12 @@ export class UsageSession {
): Promise<void> {
try {
if (!this.options.runtime) throw new Error("Plugin runtime is unavailable");
const agent = this.options.getAgent(msg.agentId);
if (!agent) {
const resolved = await this.options.resolveAgentReference(msg.agentId);
if (!resolved.found) {
this.emitError(msg, new Error(`Agent not found: ${msg.agentId}`), "agent_not_found");
return;
}
const reference = (await agent.session?.getUsageReference?.()) ?? null;
const reference = resolved.reference;
const reportId = reference
? await this.options.runtime.resolveUsageReference(reference)
: null;
+49 -13
View File
@@ -468,34 +468,70 @@ 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, () => 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)?.();
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();
pills.set(
agentId,
client.addComposerPill({
id: "review",
title: "Open review",
workspaceId,
agentId,
Component: ReviewPill,
async onPress() {
client.openPanel("review", { workspaceId, agentId });
button: {
title: "Open review",
icon: "Scan",
label: "Review",
behavior: {
kind: "action",
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 () => {
unsubscribe();
for (const remove of pills.values()) remove();
lifetime.abort();
removeAll();
};
}
```
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.
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.
## Transform and render timeline items