diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md
index 182690d6a0..8662ee3287 100644
--- a/docs/agent-lifecycle.md
+++ b/docs/agent-lifecycle.md
@@ -34,8 +34,8 @@ workflows all live in the CLI process, and the completion notification that woul
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
-Only the Claude provider does this today; the others still report a death only when a turn happens to
-be in flight.
+Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt;
+the unfinished turn is lost.
### Cancellation
diff --git a/docs/design.md b/docs/design.md
index 541fe9c6f5..e0904d917e 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -193,7 +193,7 @@ Terminology:
Loading is inline by default. ` ` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use ``; the menu item handles its own pending state.
-Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
+Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs` `` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
diff --git a/docs/providers.md b/docs/providers.md
index f38074ce71..6ea928f3c9 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -99,6 +99,8 @@ Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
+Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
+
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
diff --git a/docs/qa-evidence/sidebar-empty-state-card/after-compact.png b/docs/qa-evidence/sidebar-empty-state-card/after-compact.png
new file mode 100644
index 0000000000..140bcf2052
Binary files /dev/null and b/docs/qa-evidence/sidebar-empty-state-card/after-compact.png differ
diff --git a/docs/qa-evidence/sidebar-empty-state-card/after-dark.png b/docs/qa-evidence/sidebar-empty-state-card/after-dark.png
new file mode 100644
index 0000000000..8a2c4bf8ae
Binary files /dev/null and b/docs/qa-evidence/sidebar-empty-state-card/after-dark.png differ
diff --git a/docs/qa-evidence/sidebar-empty-state-card/after-light.png b/docs/qa-evidence/sidebar-empty-state-card/after-light.png
new file mode 100644
index 0000000000..e283932a5b
Binary files /dev/null and b/docs/qa-evidence/sidebar-empty-state-card/after-light.png differ
diff --git a/docs/qa-evidence/sidebar-empty-state-card/before-dark.png b/docs/qa-evidence/sidebar-empty-state-card/before-dark.png
new file mode 100644
index 0000000000..0b5269331f
Binary files /dev/null and b/docs/qa-evidence/sidebar-empty-state-card/before-dark.png differ
diff --git a/docs/qa-evidence/sidebar-empty-state-card/before-light.png b/docs/qa-evidence/sidebar-empty-state-card/before-light.png
new file mode 100644
index 0000000000..95ce14ad3a
Binary files /dev/null and b/docs/qa-evidence/sidebar-empty-state-card/before-light.png differ
diff --git a/packages/app/e2e/browser/content-width.spec.ts b/packages/app/e2e/browser/content-width.spec.ts
new file mode 100644
index 0000000000..bd3cc4394f
--- /dev/null
+++ b/packages/app/e2e/browser/content-width.spec.ts
@@ -0,0 +1,70 @@
+import { test } from "../support/fixtures";
+import {
+ expectContentColumnWidth,
+ expectCustomContentWidthSetting,
+ expectDefaultContentWidthSetting,
+ openAppearanceSettings,
+ resetContentWidth,
+ setContentWidth,
+} from "../support/helpers/content-width";
+import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
+import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+
+const ULTRAWIDE_VIEWPORT = { width: 2560, height: 1080 };
+
+test("widens chat and Markdown files to the chosen content width and resets to the default", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(120_000);
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "content-width-",
+ title: "Content width",
+ initialPrompt: "Write a long paragraph.",
+ });
+
+ try {
+ await agent.client.waitForFinish(agent.agentId, 30_000);
+ await page.setViewportSize(ULTRAWIDE_VIEWPORT);
+ const assistantMessage = page.getByTestId("assistant-message").first();
+ const markdownPreview = page.getByTestId("markdown-preview-frame").first();
+
+ await test.step("chat starts at the default width", async () => {
+ await openAgentRoute(page, agent);
+ await expectContentColumnWidth(assistantMessage, { max: 820 });
+ });
+
+ await test.step("settings show the default without a reset", async () => {
+ await openAppearanceSettings(page);
+ await expectDefaultContentWidthSetting(page, 820);
+ });
+
+ await test.step("a custom width is saved and offers a reset", async () => {
+ await setContentWidth(page, 1600);
+ await expectCustomContentWidthSetting(page, 1600);
+ await page.screenshot({ path: testInfo.outputPath("settings-custom-width.png") });
+ });
+
+ await test.step("chat and Markdown files use the custom width", async () => {
+ await openAgentRoute(page, agent);
+ await expectContentColumnWidth(assistantMessage, { min: 1200, max: 1600 });
+ await page.screenshot({ path: testInfo.outputPath("chat-custom-width.png") });
+
+ await openFileExplorer(page);
+ await openFileFromExplorer(page, "README.md");
+ await expectContentColumnWidth(markdownPreview, { min: 1200, max: 1600 });
+ await page.screenshot({ path: testInfo.outputPath("markdown-custom-width.png") });
+ });
+
+ await test.step("reset returns to the default without storing it", async () => {
+ await openAppearanceSettings(page);
+ await resetContentWidth(page);
+ await expectDefaultContentWidthSetting(page, 820);
+
+ await openAgentRoute(page, agent);
+ await expectContentColumnWidth(assistantMessage, { max: 820 });
+ await page.screenshot({ path: testInfo.outputPath("chat-default-width.png") });
+ });
+ } finally {
+ await agent.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/plugin-settings.spec.ts b/packages/app/e2e/browser/plugin-settings.spec.ts
index 29f42988ab..6235cd647f 100644
--- a/packages/app/e2e/browser/plugin-settings.spec.ts
+++ b/packages/app/e2e/browser/plugin-settings.spec.ts
@@ -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 } });
diff --git a/packages/app/e2e/support/helpers/content-width.ts b/packages/app/e2e/support/helpers/content-width.ts
new file mode 100644
index 0000000000..a99ee6a956
--- /dev/null
+++ b/packages/app/e2e/support/helpers/content-width.ts
@@ -0,0 +1,63 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+import { openSettings } from "./app";
+import { openSettingsSection } from "./settings";
+
+const APP_SETTINGS_KEY = "@paseo:app-settings";
+
+function contentWidthInput(page: Page): Locator {
+ return page.getByLabel("Content width in pixels", { exact: true });
+}
+
+function resetContentWidthButton(page: Page): Locator {
+ return page.getByRole("button", { name: "Reset content width to default", exact: true });
+}
+
+export async function openAppearanceSettings(page: Page): Promise {
+ await openSettings(page);
+ await openSettingsSection(page, "appearance");
+}
+
+export async function setContentWidth(page: Page, width: number): Promise {
+ const input = contentWidthInput(page);
+ await input.fill(String(width));
+ await input.press("Tab");
+}
+
+export async function resetContentWidth(page: Page): Promise {
+ await resetContentWidthButton(page).click();
+}
+
+export async function expectDefaultContentWidthSetting(page: Page, width: number): Promise {
+ await expect(contentWidthInput(page)).toHaveValue(String(width));
+ await expect(resetContentWidthButton(page)).toHaveCount(0);
+ await expect.poll(() => readStoredContentWidth(page)).toBeNull();
+}
+
+export async function expectCustomContentWidthSetting(page: Page, width: number): Promise {
+ await expect(contentWidthInput(page)).toHaveValue(String(width));
+ await expect(resetContentWidthButton(page)).toBeVisible();
+ await expect.poll(() => readStoredContentWidth(page)).toBe(width);
+}
+
+/** Asserts the rendered width of a content-column element stays within `max` and above `min`. */
+export async function expectContentColumnWidth(
+ element: Locator,
+ bounds: { min?: number; max: number },
+): Promise {
+ await expect(element).toBeVisible({ timeout: 30_000 });
+ await expect
+ .poll(async () => (await element.boundingBox())?.width ?? 0)
+ .toBeLessThanOrEqual(bounds.max);
+ await expect
+ .poll(async () => (await element.boundingBox())?.width ?? 0)
+ .toBeGreaterThan(bounds.min ?? 0);
+}
+
+async function readStoredContentWidth(page: Page): Promise {
+ return page.evaluate((key) => {
+ const raw = localStorage.getItem(key);
+ return raw === null
+ ? undefined
+ : (JSON.parse(raw) as { contentMaxWidth?: unknown }).contentMaxWidth;
+ }, APP_SETTINGS_KEY);
+}
diff --git a/packages/app/e2e/support/helpers/plugin-settings.ts b/packages/app/e2e/support/helpers/plugin-settings.ts
index 94143f31a2..32f07fcdfd 100644
--- a/packages/app/e2e/support/helpers/plugin-settings.ts
+++ b/packages/app/e2e/support/helpers/plugin-settings.ts
@@ -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>;
@@ -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 }),
diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx
index 1d86943e05..c0fe52e3fc 100644
--- a/packages/app/src/agent-stream/strategy-web.test.tsx
+++ b/packages/app/src/agent-stream/strategy-web.test.tsx
@@ -1,6 +1,7 @@
/**
* @vitest-environment jsdom
*/
+import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
@@ -144,6 +145,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
})}
>,
);
@@ -187,6 +189,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
);
});
@@ -233,6 +236,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -291,6 +295,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
const unrevised = {
contentById: new Set(),
@@ -354,6 +359,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -429,6 +435,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
);
});
@@ -473,6 +480,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
);
});
@@ -553,6 +561,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -646,6 +655,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -755,6 +765,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -860,6 +871,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -959,6 +971,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -1056,6 +1069,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -1175,6 +1189,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -1408,6 +1423,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
const renderWithActivity = (active: boolean, input: StreamRenderInput = renderInput) => (
{strategy.render(input)}
diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx
index 4b0d73d681..c798c374d4 100644
--- a/packages/app/src/agent-stream/strategy-web.tsx
+++ b/packages/app/src/agent-stream/strategy-web.tsx
@@ -303,6 +303,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
hasOlderHistory,
olderHistoryProgressKey,
scrollEnabled,
+ contentMaxWidth,
isMobileBreakpoint,
} = props;
const historyVirtualized = useRevisedHistoryRows(
@@ -403,7 +404,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
estimateSize: (index: number) => {
const row = segments.historyVirtualized[index];
- return row ? estimateStreamItemHeight(row) : 120;
+ return row ? estimateStreamItemHeight(row, contentMaxWidth) : 120;
},
measureElement: measureVirtualElement,
rangeExtractor,
diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts
index ce56547a5e..5f43f8f6b1 100644
--- a/packages/app/src/agent-stream/strategy.ts
+++ b/packages/app/src/agent-stream/strategy.ts
@@ -92,6 +92,8 @@ export interface StreamRenderInput {
listStyle: StyleProp;
baseListContentContainerStyle: StyleProp;
forwardListContentContainerStyle: StyleProp;
+ // The chat column's max width. The web viewport estimates unmeasured row heights with it.
+ contentMaxWidth: number;
}
export interface ResolveStreamRenderStrategyInput {
diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx
index d1ddd40697..acbd920b81 100644
--- a/packages/app/src/agent-stream/turn-footer.tsx
+++ b/packages/app/src/agent-stream/turn-footer.tsx
@@ -1,7 +1,6 @@
import React, { memo, useCallback, useMemo, type ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
-import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { SPACING, type Theme } from "@/styles/theme";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
@@ -215,7 +214,7 @@ function TurnFooterRow({ children }: { children: ReactNode }) {
const stylesheet = StyleSheet.create((theme) => ({
streamItemWrapper: {
width: "100%",
- maxWidth: MAX_CONTENT_WIDTH,
+ maxWidth: theme.contentMaxWidth,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx
index 3fe17a7bee..bf13ca6330 100644
--- a/packages/app/src/agent-stream/view.tsx
+++ b/packages/app/src/agent-stream/view.tsx
@@ -23,7 +23,7 @@ import {
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
-import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
+import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native";
@@ -55,7 +55,7 @@ import { useSessionStore } from "@/stores/session-store";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
-import { useSettings } from "@/hooks/use-settings";
+import { resolveContentMaxWidth, useSettings } from "@/hooks/use-settings";
import type { ToastApi } from "@/components/toast-host";
import { returnToTimelineTail } from "./timeline-tail-navigation";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -349,6 +349,7 @@ const AgentStreamViewComponent = forwardRef settings.autoExpandReasoning);
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
+ const contentMaxWidth = useSettings(resolveContentMaxWidth);
const viewportRef = useRef(null);
const pendingClientMessageIds = useMemo(
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
@@ -1129,6 +1130,7 @@ const AgentStreamViewComponent = forwardRef
({
},
contentWrapper: {
width: "100%",
- maxWidth: MAX_CONTENT_WIDTH,
+ maxWidth: theme.contentMaxWidth,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
@@ -1643,7 +1645,7 @@ const stylesheet = StyleSheet.create((theme) => ({
},
streamItemWrapper: {
width: "100%",
- maxWidth: MAX_CONTENT_WIDTH,
+ maxWidth: theme.contentMaxWidth,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
diff --git a/packages/app/src/agent-stream/web-virtualization.test.ts b/packages/app/src/agent-stream/web-virtualization.test.ts
index 0e45efa65d..fc3d82b35c 100644
--- a/packages/app/src/agent-stream/web-virtualization.test.ts
+++ b/packages/app/src/agent-stream/web-virtualization.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import type { StreamItem } from "@/types/stream";
import {
clearAssistantImageMetadataCache,
@@ -123,8 +124,8 @@ describe("splitWebVirtualizedHistory", () => {
describe("estimateStreamItemHeight", () => {
it("uses compact estimates for collapsed tool sequence rows", () => {
- expect(estimateStreamItemHeight(toolCall("tool", 1))).toBe(40);
- expect(estimateStreamItemHeight(thought("thought", 2))).toBe(40);
+ expect(estimateStreamItemHeight(toolCall("tool", 1), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
+ expect(estimateStreamItemHeight(thought("thought", 2), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
});
it("uses a larger estimate for user messages with image attachments", () => {
@@ -146,7 +147,7 @@ describe("estimateStreamItemHeight", () => {
],
};
- expect(estimateStreamItemHeight(item)).toBe(220);
+ expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBe(220);
});
it("uses cached assistant image metadata when available", () => {
@@ -165,7 +166,7 @@ describe("estimateStreamItemHeight", () => {
timestamp: createTimestamp(2),
};
- expect(estimateStreamItemHeight(item)).toBeGreaterThan(220);
+ expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBeGreaterThan(220);
});
});
diff --git a/packages/app/src/agent-stream/web-virtualization.ts b/packages/app/src/agent-stream/web-virtualization.ts
index 21ba5d64dd..13bc737e5f 100644
--- a/packages/app/src/agent-stream/web-virtualization.ts
+++ b/packages/app/src/agent-stream/web-virtualization.ts
@@ -58,12 +58,12 @@ export interface WebVirtualizedHistoryWindow {
mountedEntries: IndexedStreamItem[];
}
-export function estimateStreamItemHeight(item: StreamItem): number {
+export function estimateStreamItemHeight(item: StreamItem, contentMaxWidth: number): number {
switch (item.kind) {
case "user_message":
return item.images && item.images.length > 0 ? 220 : 96;
case "assistant_message":
- return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
+ return estimateAssistantMessageHeightFromCache(item.text, contentMaxWidth) ?? 220;
case "tool_call":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "thought":
diff --git a/packages/app/src/appearance/apply.test.ts b/packages/app/src/appearance/apply.test.ts
index 9780d00738..e18eaa6555 100644
--- a/packages/app/src/appearance/apply.test.ts
+++ b/packages/app/src/appearance/apply.test.ts
@@ -37,6 +37,7 @@ interface FakeTheme {
"4xl": number;
};
lineHeight: { diff: number };
+ contentMaxWidth: number;
colors: { foreground: string; syntax: Record };
}
@@ -56,6 +57,7 @@ function makeFakeTheme(): FakeTheme {
"4xl": 26,
},
lineHeight: { diff: 22 },
+ contentMaxWidth: 820,
colors: { foreground: "#fff", syntax: {} },
};
}
@@ -67,6 +69,7 @@ function makeInput(overrides: Partial = {}): AppearanceInput {
uiBaseFontSize: 14,
contentFontSize: 15,
codeFontSize: 12,
+ contentMaxWidth: 820,
syntaxTheme: "one",
...overrides,
};
@@ -102,6 +105,12 @@ describe("applyAppearance", () => {
]);
});
+ it("patches the content max width into the theme", () => {
+ applyAppearance(makeInput({ contentMaxWidth: 1600 }));
+
+ expect(runCapturedUpdater().contentMaxWidth).toBe(1600);
+ });
+
it("resolves an empty UI font family to the default stack", () => {
applyAppearance(makeInput({ uiFontFamily: "" }));
diff --git a/packages/app/src/appearance/apply.ts b/packages/app/src/appearance/apply.ts
index 4d840265f0..04032a00b0 100644
--- a/packages/app/src/appearance/apply.ts
+++ b/packages/app/src/appearance/apply.ts
@@ -17,6 +17,7 @@ export interface AppearanceInput {
uiBaseFontSize: number; // already clamped
contentFontSize: number; // already clamped
codeFontSize: number; // already clamped
+ contentMaxWidth: number; // already clamped, default resolved
syntaxTheme: SyntaxThemeId;
}
@@ -55,7 +56,7 @@ function scaleFontSize(
* always current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant.
*
* The updater preserves the active theme wholesale (surfaces, accents,
- * terminal) and only patches the font ramp and syntax palette.
+ * terminal) and only patches the font ramp, content width, and syntax palette.
* `updateTheme` replaces the stored theme rather than merging, so we spread
* `...t` first.
*/
@@ -86,6 +87,7 @@ export function applyAppearance(input: AppearanceInput): void {
fontFamily,
fontSize,
lineHeight,
+ contentMaxWidth: input.contentMaxWidth,
colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) },
};
}
@@ -94,6 +96,7 @@ export function applyAppearance(input: AppearanceInput): void {
fontFamily,
fontSize,
lineHeight,
+ contentMaxWidth: input.contentMaxWidth,
colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) },
};
});
diff --git a/packages/app/src/appearance/provider.tsx b/packages/app/src/appearance/provider.tsx
index 9b09d987c9..d52864e435 100644
--- a/packages/app/src/appearance/provider.tsx
+++ b/packages/app/src/appearance/provider.tsx
@@ -8,7 +8,12 @@ import {
useState,
} from "react";
import { UnistylesRuntime } from "react-native-unistyles";
-import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
+import {
+ DEFAULT_THEME_PREFERENCE,
+ resolveContentMaxWidth,
+ useAppSettings,
+ type AppSettings,
+} from "@/hooks/use-settings";
import {
rememberPluginThemeHost,
usePluginThemeCatalog,
@@ -68,6 +73,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
uiBaseFontSize: settings.uiBaseFontSize,
contentFontSize: settings.contentFontSize,
codeFontSize: settings.codeFontSize,
+ contentMaxWidth: resolveContentMaxWidth({ contentMaxWidth: settings.contentMaxWidth }),
syntaxTheme: settings.syntaxTheme,
});
setHasAppliedAppearance(true);
@@ -80,6 +86,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
settings.uiBaseFontSize,
settings.contentFontSize,
settings.codeFontSize,
+ settings.contentMaxWidth,
settings.syntaxTheme,
]);
diff --git a/packages/app/src/components/archived-agent-callout.tsx b/packages/app/src/components/archived-agent-callout.tsx
index 5889ebbb26..b5f6a3ea54 100644
--- a/packages/app/src/components/archived-agent-callout.tsx
+++ b/packages/app/src/components/archived-agent-callout.tsx
@@ -2,7 +2,7 @@ import { useCallback, useState } from "react";
import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
-import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
+import { FOOTER_HEIGHT } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { KeyboardTranslateView } from "@/keyboard/shift";
import { Button } from "@/components/ui/button";
@@ -77,7 +77,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
inputAreaContent: {
width: "100%",
- maxWidth: MAX_CONTENT_WIDTH,
+ maxWidth: theme.contentMaxWidth,
},
callout: {
flexDirection: "row",
diff --git a/packages/app/src/components/sidebar/empty-states.tsx b/packages/app/src/components/sidebar/empty-states.tsx
index 4aaf93e0cc..3328ed2c5c 100644
--- a/packages/app/src/components/sidebar/empty-states.tsx
+++ b/packages/app/src/components/sidebar/empty-states.tsx
@@ -1,4 +1,4 @@
-import { useCallback } from "react";
+import { type ReactNode, useCallback } from "react";
import { Import, Plus } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
@@ -6,6 +6,28 @@ import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
+function SidebarEmptyStateCard({
+ testID,
+ title,
+ description,
+ children,
+}: {
+ testID: string;
+ title: string;
+ description: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {title}
+ {description}
+
+ {children}
+
+ );
+}
+
/**
* The two things the sidebar's workspace list says when it has no rows.
*
@@ -27,13 +49,15 @@ export function SidebarFilterEmptyState() {
}, [clearLabelFilter, clearProjectFilters]);
return (
-
- {t("sidebar.filterEmpty.title")}
- {t("sidebar.filterEmpty.description")}
-
+
+
{t("sidebar.filterEmpty.clear")}
-
+
);
}
@@ -47,40 +71,44 @@ export function SidebarProjectEmptyState({
const { t } = useTranslation();
return (
-
- {t("sidebar.project.empty.title")}
- {t("sidebar.project.empty.description")}
-
+
+
{t("sidebar.actions.addProject")}
-
+
{t("importSession.title")}
-
+
);
}
const styles = StyleSheet.create((theme) => ({
- container: {
- marginHorizontal: theme.spacing[2],
- marginTop: theme.spacing[4],
- paddingTop: theme.spacing[6],
- paddingBottom: theme.spacing[4],
- paddingHorizontal: theme.spacing[4],
+ card: {
+ marginTop: theme.spacing[3],
+ padding: theme.spacing[4],
+ borderWidth: 1,
+ borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
- backgroundColor: theme.colors.surface0,
- alignItems: "center",
- gap: theme.spacing[3],
+ },
+ copy: {
+ gap: theme.spacing[1],
+ },
+ actions: {
+ flexDirection: "row",
+ gap: theme.spacing[2],
+ marginTop: theme.spacing[4],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
- fontWeight: theme.fontWeight.medium,
- textAlign: "center",
+ fontWeight: theme.fontWeight.normal,
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
- textAlign: "center",
},
}));
diff --git a/packages/app/src/composer/dock/index.native.tsx b/packages/app/src/composer/dock/index.native.tsx
index 49fb6a15a4..91bb98e7a8 100644
--- a/packages/app/src/composer/dock/index.native.tsx
+++ b/packages/app/src/composer/dock/index.native.tsx
@@ -2,7 +2,8 @@ import { ComposerDockBackground } from "./internal/background";
export { ComposerDockBackground } from "./internal/background";
import { ScrollView } from "@/components/ui/scroll-view";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
+import { HEADER_INNER_HEIGHT } from "@/constants/layout";
+import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
import { KeyboardTranslateView } from "@/keyboard/shift";
import { createContext, useCallback, useContext, type ReactNode } from "react";
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
@@ -89,6 +90,7 @@ export function ComposerDock({
centered = false,
}: ComposerDockProps) {
const insets = useSafeAreaInsets();
+ const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
// Preserve the existing centered form's visual balance on tablets.
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
if (centered) {
@@ -98,7 +100,7 @@ export function ComposerDock({
bottomInset={bottomInset}
centered
>
-
+
{content}
@@ -137,13 +139,15 @@ export function ComposerDock({
);
}
+// Plain React Native styles: these land on Reanimated views, which must not carry
+// theme-driven Unistyles (docs/unistyles.md). The content width arrives inline.
const dockStyles = StyleSheet.create({
viewport: { flex: 1, overflow: "hidden" },
surface: { flex: 1 },
content: { flex: 1, justifyContent: "flex-end" },
composer: { width: "100%", flexShrink: 1 },
centeredViewport: { flex: 1, alignItems: "center", justifyContent: "center" },
- centered: { flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH },
+ centered: { flexShrink: 1, width: "100%" },
// Reserve the composer's own capped height before the setup scroll view shrinks.
centeredComposer: { width: "100%", flexShrink: 0 },
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
diff --git a/packages/app/src/composer/dock/index.tsx b/packages/app/src/composer/dock/index.tsx
index 39a8e24cbf..d39439f325 100644
--- a/packages/app/src/composer/dock/index.tsx
+++ b/packages/app/src/composer/dock/index.tsx
@@ -1,8 +1,9 @@
export { ComposerDockBackground } from "./internal/background";
import { ScrollView } from "@/components/ui/scroll-view";
import type { ReactNode } from "react";
-import { View, StyleSheet } from "react-native";
-import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
+import { View } from "react-native";
+import { StyleSheet } from "react-native-unistyles";
+import { HEADER_INNER_HEIGHT } from "@/constants/layout";
interface ComposerDockProps {
children: [ReactNode, ReactNode, ReactNode?];
@@ -35,7 +36,7 @@ export function ComposerDock({
);
}
-const styles = StyleSheet.create({
+const styles = StyleSheet.create((theme) => ({
surface: { flex: 1, overflow: "hidden" },
content: { flex: 1, justifyContent: "flex-end" },
composer: { width: "100%", flexShrink: 1 },
@@ -45,7 +46,7 @@ const styles = StyleSheet.create({
justifyContent: "center",
paddingBottom: HEADER_INNER_HEIGHT + 24,
},
- form: { flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH },
+ form: { flexShrink: 1, width: "100%", maxWidth: theme.contentMaxWidth },
centeredComposer: { flexShrink: 0 },
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
-});
+}));
diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx
index 519e22e940..6e9c45eab5 100644
--- a/packages/app/src/composer/draft/workspace-tab.tsx
+++ b/packages/app/src/composer/draft/workspace-tab.tsx
@@ -40,11 +40,7 @@ import {
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { UserMessageImageAttachment } from "@/types/stream";
-import {
- COMPACT_FORM_FACTOR_WIDTH,
- MAX_CONTENT_WIDTH,
- useIsCompactFormFactor,
-} from "@/constants/layout";
+import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import {
buildWorkspaceTabPersistenceKey,
@@ -737,7 +733,7 @@ const styles = StyleSheet.create((theme) => ({
},
importPillContent: {
width: "100%",
- maxWidth: MAX_CONTENT_WIDTH,
+ maxWidth: theme.contentMaxWidth,
flexDirection: "row",
},
errorContainer: {
diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx
index ef6e27d3ac..28d23ae214 100644
--- a/packages/app/src/composer/index.tsx
+++ b/packages/app/src/composer/index.tsx
@@ -41,7 +41,7 @@ import {
Paperclip,
} from "lucide-react-native";
import * as Clipboard from "expo-clipboard";
-import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
+import { FOOTER_HEIGHT } from "@/constants/layout";
import {
AgentControls,
DraftAgentControls,
@@ -2542,7 +2542,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
inputAreaContent: {
flexShrink: 1,
width: "100%",
- maxWidth: MAX_CONTENT_WIDTH,
+ maxWidth: theme.contentMaxWidth,
gap: theme.spacing[3],
},
messageInputContainer: {
diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx
index 3ff9e765c6..e33cd6ba53 100644
--- a/packages/app/src/composer/tracks.tsx
+++ b/packages/app/src/composer/tracks.tsx
@@ -11,7 +11,6 @@ import {
} from "@/components/ui/menu";
import { StatusRing } from "@/components/status-ring";
import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry";
-import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
@@ -328,7 +327,7 @@ const styles = StyleSheet.create((theme) => {
},
track: {
width: "100%",
- maxWidth: MAX_CONTENT_WIDTH,
+ maxWidth: theme.contentMaxWidth,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts
index 3e159041e2..721708715c 100644
--- a/packages/app/src/constants/layout.ts
+++ b/packages/app/src/constants/layout.ts
@@ -11,8 +11,6 @@ export const HEADER_INNER_HEIGHT_MOBILE = 56;
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
export const HEADER_TOP_PADDING_MOBILE = 8;
-// Max width for chat content (stream view, input area, new agent form)
-export const MAX_CONTENT_WIDTH = 820;
export const COMPACT_FORM_FACTOR_WIDTH = 500;
// Settings uses the canonical desktop list + detail layout. Its sidebar and
diff --git a/packages/app/src/file-pane/markdown-preview/index.tsx b/packages/app/src/file-pane/markdown-preview/index.tsx
index f1247f1c91..e8d172c61b 100644
--- a/packages/app/src/file-pane/markdown-preview/index.tsx
+++ b/packages/app/src/file-pane/markdown-preview/index.tsx
@@ -2,7 +2,6 @@ import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { MarkdownRenderer } from "@/components/markdown/renderer";
-import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { parseMarkdownPreviewDocument } from "./document";
export function FileMarkdownPreview({ source }: { source: string }) {
@@ -10,7 +9,7 @@ export function FileMarkdownPreview({ source }: { source: string }) {
return (
-
+
{document.frontMatter.length > 0 ? (
{document.frontMatter.map((row, index) => (
@@ -49,7 +48,7 @@ const styles = StyleSheet.create((theme) => ({
},
readingFrame: {
width: "100%",
- maxWidth: MAX_CONTENT_WIDTH,
+ maxWidth: theme.contentMaxWidth,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts
index 704af34976..66a8e767b1 100644
--- a/packages/app/src/hooks/use-settings/index.ts
+++ b/packages/app/src/hooks/use-settings/index.ts
@@ -17,22 +17,27 @@ import {
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_CONTENT_FONT_SIZE,
+ DEFAULT_CONTENT_MAX_WIDTH,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_THEME_PREFERENCE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_CONTENT_FONT_SIZE,
+ MAX_CONTENT_MAX_WIDTH,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_BASE_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_CONTENT_FONT_SIZE,
+ MIN_CONTENT_MAX_WIDTH,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_BASE_FONT_SIZE,
loadAppSettingsFromStorage as loadAppSettingsFromStoragePure,
loadSettingsFromStorage as loadSettingsFromStoragePure,
normalizeAppSettings,
parseClampedFontSize,
+ parseContentMaxWidth,
parseTerminalScrollbackLines,
+ resolveContentMaxWidth,
sanitizeFontFamily,
saveAppSettings as saveAppSettingsPure,
type AppSettings,
@@ -56,19 +61,24 @@ export {
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_CONTENT_FONT_SIZE,
+ DEFAULT_CONTENT_MAX_WIDTH,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_THEME_PREFERENCE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_CONTENT_FONT_SIZE,
+ MAX_CONTENT_MAX_WIDTH,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_BASE_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_CONTENT_FONT_SIZE,
+ MIN_CONTENT_MAX_WIDTH,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_BASE_FONT_SIZE,
parseClampedFontSize,
+ parseContentMaxWidth,
parseTerminalScrollbackLines,
+ resolveContentMaxWidth,
sanitizeFontFamily,
};
export type {
diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts
index 51e2831338..df738d7247 100644
--- a/packages/app/src/hooks/use-settings/storage.test.ts
+++ b/packages/app/src/hooks/use-settings/storage.test.ts
@@ -14,6 +14,7 @@ import {
loadSettingsFromStorage,
parseClampedFontSize,
parseTerminalScrollbackLines,
+ resolveContentMaxWidth,
saveAppSettings,
type SettingsDeps,
} from "./storage";
@@ -22,7 +23,7 @@ import {
DEFAULT_SIDEBAR_ROW_ITEMS,
SIDEBAR_ROW_ITEMS,
} from "@/components/sidebar/display-preferences/row-items";
-import { THEME_OPTIONS } from "@/styles/theme";
+import { DEFAULT_CONTENT_MAX_WIDTH, THEME_OPTIONS } from "@/styles/theme";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
@@ -955,3 +956,49 @@ describe("parseClampedFontSize", () => {
expect(parseClampedFontSize("abc", { min: 11, max: 24 })).toBeNull();
});
});
+
+describe("content max width", () => {
+ it("follows the current default until the user picks a width", async () => {
+ const deps = makeDeps();
+ const queryClient = new QueryClient();
+
+ await saveAppSettings({ queryClient, updates: { codeFontSize: 14 }, deps });
+
+ const stored = JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null");
+ expect(stored.contentMaxWidth).toBeNull();
+ expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe(
+ DEFAULT_CONTENT_MAX_WIDTH,
+ );
+ });
+
+ it("keeps a picked width and returns to the default when reset", async () => {
+ const deps = makeDeps();
+ const queryClient = new QueryClient();
+
+ await saveAppSettings({ queryClient, updates: { contentMaxWidth: 1600 }, deps });
+ expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe(1600);
+
+ await saveAppSettings({ queryClient, updates: { contentMaxWidth: null }, deps });
+ expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null").contentMaxWidth).toBe(
+ null,
+ );
+ expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe(
+ DEFAULT_CONTENT_MAX_WIDTH,
+ );
+ });
+
+ it("clamps stored widths and drops malformed ones to the default", async () => {
+ const load = (contentMaxWidth: unknown) =>
+ loadAppSettingsFromStorage(
+ makeDeps({
+ storage: createInMemoryKeyValueStorage({
+ [APP_SETTINGS_KEY]: JSON.stringify({ contentMaxWidth }),
+ }),
+ }),
+ );
+
+ expect((await load(100_000)).contentMaxWidth).toBe(4000);
+ expect((await load(10)).contentMaxWidth).toBe(600);
+ expect((await load("wide")).contentMaxWidth).toBeNull();
+ });
+});
diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts
index 7a47bc7e6c..7171505492 100644
--- a/packages/app/src/hooks/use-settings/storage.ts
+++ b/packages/app/src/hooks/use-settings/storage.ts
@@ -15,6 +15,7 @@ import {
} from "@/components/sidebar/display-preferences/row-items";
import { isNative } from "@/constants/platform";
import {
+ DEFAULT_CONTENT_MAX_WIDTH,
FONT_SIZE,
PLUGIN_THEME_PREFERENCE,
THEME_OPTIONS,
@@ -63,6 +64,9 @@ export const DEFAULT_CODE_FONT_SIZE = 12; // == FONT_SIZE.code
export const MIN_CODE_FONT_SIZE = 9;
export const MAX_CODE_FONT_SIZE = 22; // line-height 1.5×22=33 stays safe
export const MAX_FONT_FAMILY_LENGTH = 200;
+export { DEFAULT_CONTENT_MAX_WIDTH };
+export const MIN_CONTENT_MAX_WIDTH = 600;
+export const MAX_CONTENT_MAX_WIDTH = 4000;
export interface AppSettings {
theme: ThemePreference;
@@ -78,6 +82,8 @@ export interface AppSettings {
uiBaseFontSize: number; // clamped px, platform default 14 or 15
contentFontSize: number; // clamped px, platform default 15 or 16
codeFontSize: number; // clamped px, default 12
+ /** Max width of chat and markdown content in px; null follows the current default. */
+ contentMaxWidth: number | null;
syntaxTheme: SyntaxThemeId; // default "one"
workspaceTitleSource: WorkspaceTitleSource;
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
@@ -132,6 +138,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
uiBaseFontSize: DEFAULT_UI_BASE_FONT_SIZE,
contentFontSize: DEFAULT_CONTENT_FONT_SIZE,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
+ contentMaxWidth: null,
syntaxTheme: "one",
workspaceTitleSource: "title",
sidebarWorkspaceTrailing: "diff",
@@ -218,6 +225,10 @@ const StoredAppSettingsSchema = z
codeFontSize: clampedNumber(MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE).catch(
DEFAULT_CODE_FONT_SIZE,
),
+ contentMaxWidth: z
+ .null()
+ .or(clampedNumber(MIN_CONTENT_MAX_WIDTH, MAX_CONTENT_MAX_WIDTH))
+ .catch(null),
syntaxTheme: z.string().refine(isSyntaxThemeId).catch("one"),
workspaceTitleSource: z.enum(["title", "branch"]).catch("title"),
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("diff"),
@@ -457,6 +468,14 @@ export function parseTerminalScrollbackLines(value: unknown): number | null {
);
}
+export function parseContentMaxWidth(value: unknown): number | null {
+ return parseClampedFontSize(value, { min: MIN_CONTENT_MAX_WIDTH, max: MAX_CONTENT_MAX_WIDTH });
+}
+
+export function resolveContentMaxWidth(settings: Pick): number {
+ return settings.contentMaxWidth ?? DEFAULT_CONTENT_MAX_WIDTH;
+}
+
export function parseClampedFontSize(
value: unknown,
bounds: { min: number; max: number },
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index b32fa13c70..a418df723f 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -2191,6 +2191,14 @@ export const ar: TranslationResources = {
codeSizeHint: "يُستخدم للكود والفروقات ومخرجات الطرفية",
codeSizeAccessibility: "حجم خط الكود",
},
+ layout: {
+ title: "التخطيط",
+ contentWidth: "عرض المحتوى",
+ contentWidthHint: "أقصى عرض للمحادثة وملفات Markdown على الشاشات العريضة",
+ contentWidthAccessibility: "عرض المحتوى بالبكسل",
+ reset: "إعادة تعيين",
+ resetAccessibility: "إعادة عرض المحتوى إلى الافتراضي",
+ },
syntax: {
title: "بناء الجملة",
highlightTheme: "تسليط الضوء على الموضوع",
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index ef1e65f2ef..09774ae914 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -2071,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:
@@ -2314,6 +2315,14 @@ export const en = {
codeSizeHint: "Used for code, diffs, and terminal output",
codeSizeAccessibility: "Code font size",
},
+ layout: {
+ title: "Layout",
+ contentWidth: "Content width",
+ contentWidthHint: "Max width of chat and Markdown files on wide screens",
+ contentWidthAccessibility: "Content width in pixels",
+ reset: "Reset",
+ resetAccessibility: "Reset content width to default",
+ },
syntax: {
title: "Syntax",
highlightTheme: "Highlight theme",
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index 0d4dc5a43e..3227988de9 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -2245,6 +2245,14 @@ export const es: TranslationResources = {
codeSizeHint: "Se usa en código, diferencias y la salida del terminal",
codeSizeAccessibility: "Tamaño de fuente del código",
},
+ layout: {
+ title: "Diseño",
+ contentWidth: "Ancho del contenido",
+ contentWidthHint: "Ancho máximo del chat y de los archivos Markdown en pantallas anchas",
+ contentWidthAccessibility: "Ancho del contenido en píxeles",
+ reset: "Restablecer",
+ resetAccessibility: "Restablecer el ancho del contenido",
+ },
syntax: {
title: "Sintaxis",
highlightTheme: "Tema destacado",
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index e3721eb523..ca3343000f 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -2248,6 +2248,14 @@ export const fr: TranslationResources = {
codeSizeHint: "Utilisée pour le code, les diffs et la sortie du terminal",
codeSizeAccessibility: "Taille de la police du code",
},
+ layout: {
+ title: "Mise en page",
+ contentWidth: "Largeur du contenu",
+ contentWidthHint: "Largeur maximale du chat et des fichiers Markdown sur les grands écrans",
+ contentWidthAccessibility: "Largeur du contenu en pixels",
+ reset: "Réinitialiser",
+ resetAccessibility: "Réinitialiser la largeur du contenu",
+ },
syntax: {
title: "Syntaxe",
highlightTheme: "Thème de surbrillance",
diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts
index beee86a4f4..31c4c8fb61 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -2211,6 +2211,14 @@ export const ja: TranslationResources = {
codeSizeHint: "コード、差分、ターミナル出力に使用されます",
codeSizeAccessibility: "コードフォントサイズ",
},
+ layout: {
+ title: "レイアウト",
+ contentWidth: "コンテンツ幅",
+ contentWidthHint: "ワイド画面でのチャットと Markdown ファイルの最大幅",
+ contentWidthAccessibility: "コンテンツ幅 (ピクセル)",
+ reset: "リセット",
+ resetAccessibility: "コンテンツ幅をデフォルトに戻す",
+ },
syntax: {
title: "構文ハイライト",
highlightTheme: "ハイライトテーマ",
diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts
index d70c345137..cf5ca5ea41 100644
--- a/packages/app/src/i18n/resources/ko.ts
+++ b/packages/app/src/i18n/resources/ko.ts
@@ -2202,6 +2202,14 @@ export const ko: TranslationResources = {
codeSizeHint: "코드, diff 및 터미널 출력에 사용됩니다",
codeSizeAccessibility: "코드 글꼴 크기",
},
+ layout: {
+ title: "레이아웃",
+ contentWidth: "콘텐츠 너비",
+ contentWidthHint: "와이드 화면에서 채팅과 Markdown 파일의 최대 너비",
+ contentWidthAccessibility: "콘텐츠 너비(픽셀)",
+ reset: "재설정",
+ resetAccessibility: "콘텐츠 너비를 기본값으로 재설정",
+ },
syntax: {
title: "구문",
highlightTheme: "강조 테마",
diff --git a/packages/app/src/i18n/resources/plugin-settings.ts b/packages/app/src/i18n/resources/plugin-settings.ts
index 15373a822d..104bf6b1d0 100644
--- a/packages/app/src/i18n/resources/plugin-settings.ts
+++ b/packages/app/src/i18n/resources/plugin-settings.ts
@@ -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: "插件是受信任的代码",
diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts
index 0ab0df227d..5d4e10c14e 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -2226,6 +2226,14 @@ export const ptBR: TranslationResources = {
codeSizeHint: "Usado em código, diffs e saída do terminal",
codeSizeAccessibility: "Tamanho da fonte de código",
},
+ layout: {
+ title: "Layout",
+ contentWidth: "Largura do conteúdo",
+ contentWidthHint: "Largura máxima do chat e dos arquivos Markdown em telas largas",
+ contentWidthAccessibility: "Largura do conteúdo em pixels",
+ reset: "Redefinir",
+ resetAccessibility: "Redefinir a largura do conteúdo",
+ },
syntax: {
title: "Sintaxe",
highlightTheme: "Tema de destaque",
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index 29090f3ad4..271eafaa28 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -2231,6 +2231,14 @@ export const ru: TranslationResources = {
codeSizeHint: "Используется для кода, диффов и вывода терминала",
codeSizeAccessibility: "Размер шрифта кода",
},
+ layout: {
+ title: "Макет",
+ contentWidth: "Ширина содержимого",
+ contentWidthHint: "Максимальная ширина чата и файлов Markdown на широких экранах",
+ contentWidthAccessibility: "Ширина содержимого в пикселях",
+ reset: "Сбросить",
+ resetAccessibility: "Сбросить ширину содержимого",
+ },
syntax: {
title: "Синтаксис",
highlightTheme: "Тема подсветки",
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index da4e6fdfb3..cdeb534e58 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -2166,6 +2166,14 @@ export const zhCN: TranslationResources = {
codeSizeHint: "用于代码、差异和终端输出",
codeSizeAccessibility: "代码字号",
},
+ layout: {
+ title: "布局",
+ contentWidth: "内容宽度",
+ contentWidthHint: "宽屏上聊天和 Markdown 文件的最大宽度",
+ contentWidthAccessibility: "内容宽度(像素)",
+ reset: "重置",
+ resetAccessibility: "将内容宽度重置为默认值",
+ },
syntax: {
title: "语法",
highlightTheme: "高亮主题",
diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx
index ac114681c8..c0669972bf 100644
--- a/packages/app/src/panels/agent-panel.tsx
+++ b/packages/app/src/panels/agent-panel.tsx
@@ -38,11 +38,7 @@ import { getProviderIcon } from "@/components/provider-icons";
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
-import {
- COMPACT_FORM_FACTOR_WIDTH,
- MAX_CONTENT_WIDTH,
- useIsCompactFormFactor,
-} from "@/constants/layout";
+import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
@@ -1750,7 +1746,7 @@ const styles = StyleSheet.create((theme) => ({
},
timelineSyncCalloutContent: {
width: "100%",
- maxWidth: MAX_CONTENT_WIDTH,
+ maxWidth: theme.contentMaxWidth,
},
timelineSyncCallout: {
flexDirection: "row",
diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx
index 191ecc20f2..11b5c199df 100644
--- a/packages/app/src/plugins/settings/index.tsx
+++ b/packages/app/src/plugins/settings/index.tsx
@@ -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 ;
+export function PluginSettingsContent({
+ onBackToPlugins,
+ showBackToPlugins,
+ ...identity
+}: SettingsIdentity & { onBackToPlugins: () => void; showBackToPlugins: boolean }) {
+ const { t } = useTranslation();
+ return (
+
+ {showBackToPlugins ? (
+
+ {t("settings.plugins.screens.backToPlugins")}
+
+ ) : null}
+
+
+ );
}
-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] },
+}));
diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx
index dc1a7c46d6..925607a858 100644
--- a/packages/app/src/screens/settings-screen.tsx
+++ b/packages/app/src/screens/settings-screen.tsx
@@ -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") {
diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx
index 56049b9b14..6e98819323 100644
--- a/packages/app/src/screens/settings/appearance/appearance-section.tsx
+++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx
@@ -1,4 +1,4 @@
-import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
+import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
@@ -18,7 +18,11 @@ import {
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { useContributedThemes } from "@/appearance/provider";
-import { EditingTextInput as TextInput } from "@/components/ui/text-input";
+import { Button } from "@/components/ui/button";
+import {
+ EditingTextInput as TextInput,
+ type EditingTextInputHandle,
+} from "@/components/ui/text-input";
import {
MAX_CODE_FONT_SIZE,
MAX_CONTENT_FONT_SIZE,
@@ -27,6 +31,8 @@ import {
MIN_CONTENT_FONT_SIZE,
MIN_UI_BASE_FONT_SIZE,
parseClampedFontSize,
+ parseContentMaxWidth,
+ resolveContentMaxWidth,
sanitizeFontFamily,
useAppSettings,
type AppSettings,
@@ -326,6 +332,74 @@ function FontSizeRow({
);
}
+// ---------------------------------------------------------------------------
+// Content width: numeric field (commit on blur/submit) + reset to the default
+// ---------------------------------------------------------------------------
+
+interface ContentWidthRowProps {
+ value: AppSettings["contentMaxWidth"];
+ onChange: (value: AppSettings["contentMaxWidth"]) => void;
+}
+
+function ContentWidthRow({ value, onChange }: ContentWidthRowProps) {
+ const { t } = useTranslation();
+ const width = resolveContentMaxWidth({ contentMaxWidth: value });
+ // The field is uncontrolled, so a saved or reset width is written into it directly.
+ const input = useRef(null);
+
+ useEffect(() => {
+ input.current?.replaceText(String(width));
+ }, [width]);
+
+ const commit = useCallback(() => {
+ const next = parseContentMaxWidth(input.current?.getText()) ?? width;
+ input.current?.replaceText(String(next));
+ // Typing the width already in effect keeps following the default.
+ if (next !== width) {
+ onChange(next);
+ }
+ }, [onChange, width]);
+
+ const reset = useCallback(() => {
+ onChange(null);
+ }, [onChange]);
+
+ return (
+
+
+ {t("settings.appearance.layout.contentWidth")}
+
+ {t("settings.appearance.layout.contentWidthHint")}
+
+
+
+ {value === null ? null : (
+
+ {t("settings.appearance.layout.reset")}
+
+ )}
+
+ px
+
+
+ );
+}
+
// ---------------------------------------------------------------------------
// Syntax highlight theme picker (commits immediately)
// ---------------------------------------------------------------------------
@@ -440,6 +514,13 @@ export function AppearanceSection() {
[selectPluginTheme],
);
+ const handleContentMaxWidthChange = useCallback(
+ (contentMaxWidth: AppSettings["contentMaxWidth"]) => {
+ void updateSettings({ contentMaxWidth });
+ },
+ [updateSettings],
+ );
+
const handleSyntaxThemeChange = useCallback(
(syntaxTheme: SyntaxThemeId) => {
void updateSettings({ syntaxTheme });
@@ -603,6 +684,14 @@ export function AppearanceSection() {
/>
+
+
+
+
+
@@ -668,6 +757,19 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
textAlign: "right",
},
+ widthInput: {
+ width: 80,
+ minHeight: 36,
+ paddingVertical: theme.spacing[2],
+ paddingHorizontal: theme.spacing[3],
+ borderRadius: theme.borderRadius.md,
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ backgroundColor: theme.colors.surface2,
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.base,
+ textAlign: "right",
+ },
unit: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts
index 7c4ba4a9a1..f58075b3a1 100644
--- a/packages/app/src/styles/theme.ts
+++ b/packages/app/src/styles/theme.ts
@@ -642,7 +642,10 @@ export const DEFAULT_MONO_FONT_STACK: string = Platform.select({
web: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
});
-// `fontSize`, `fontFamily`, and `lineHeight` are deliberately widened to plain
+// Chat and markdown content column; the appearance updater patches the user's width in.
+export const DEFAULT_CONTENT_MAX_WIDTH = 820;
+
+// `fontSize`, `fontFamily`, `lineHeight`, and `contentMaxWidth` are deliberately widened to plain
// `number`/`string` (not narrowed by `as const`) so the appearance updater can patch
// them at runtime via `UnistylesRuntime.updateTheme`. The remaining tokens keep their
// literal types.
@@ -651,6 +654,7 @@ interface CommonTheme {
fontSize: Record;
fontFamily: { ui: string; mono: string };
lineHeight: Record;
+ contentMaxWidth: number;
iconSize: typeof ICON_SIZE;
fontWeight: typeof FONT_WEIGHT;
borderRadius: typeof BORDER_RADIUS;
@@ -663,6 +667,7 @@ const commonTheme: CommonTheme = {
fontSize: FONT_SIZE,
fontFamily: { ui: DEFAULT_UI_FONT_STACK, mono: DEFAULT_MONO_FONT_STACK },
lineHeight: LINE_HEIGHT,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
iconSize: ICON_SIZE,
fontWeight: FONT_WEIGHT,
borderRadius: BORDER_RADIUS,
diff --git a/packages/app/src/utils/assistant-image-metadata.test.ts b/packages/app/src/utils/assistant-image-metadata.test.ts
index d4b99655f2..8f8cca2d3f 100644
--- a/packages/app/src/utils/assistant-image-metadata.test.ts
+++ b/packages/app/src/utils/assistant-image-metadata.test.ts
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
+import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import {
clearAssistantImageMetadataCache,
estimateAssistantMessageHeightFromCache,
@@ -52,6 +53,7 @@ describe("assistant image metadata", () => {
expect(
estimateAssistantMessageHeightFromCache(
"Here is the screenshot\n\n",
+ DEFAULT_CONTENT_MAX_WIDTH,
),
).toBeGreaterThan(220);
});
@@ -60,8 +62,14 @@ describe("assistant image metadata", () => {
const source = `data:image/png;base64,${"a".repeat(512)}`;
setAssistantImageMetadata({ source }, { width: 1200, height: 800 });
- const imageOnlyHeight = estimateAssistantMessageHeightFromCache(``);
- const mixedHeight = estimateAssistantMessageHeightFromCache(`Text\n\n`);
+ const imageOnlyHeight = estimateAssistantMessageHeightFromCache(
+ ``,
+ DEFAULT_CONTENT_MAX_WIDTH,
+ );
+ const mixedHeight = estimateAssistantMessageHeightFromCache(
+ `Text\n\n`,
+ DEFAULT_CONTENT_MAX_WIDTH,
+ );
expect(imageOnlyHeight).toBeGreaterThan(220);
expect(mixedHeight).toBeGreaterThan(imageOnlyHeight ?? 0);
diff --git a/packages/app/src/utils/assistant-image-metadata.ts b/packages/app/src/utils/assistant-image-metadata.ts
index 9f6e159b0d..76b8eafbb1 100644
--- a/packages/app/src/utils/assistant-image-metadata.ts
+++ b/packages/app/src/utils/assistant-image-metadata.ts
@@ -1,4 +1,3 @@
-import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import { createImageSourceCacheKey } from "@/attachments/utils";
@@ -14,7 +13,7 @@ const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500;
const ASSISTANT_IMAGE_PARSE_CACHE_LIMIT = 500;
const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((<[^>]+>|[^)\n]+)\)/g;
-const ASSISTANT_IMAGE_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 8;
+const ASSISTANT_IMAGE_INSET = 8;
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
const ASSISTANT_IMAGE_BLOCK_GAP = 24;
const ASSISTANT_MESSAGE_BASE_HEIGHT = 96;
@@ -177,7 +176,10 @@ export function extractAssistantImageSources(markdown: string): string[] {
return parsed.sources;
}
-export function estimateAssistantMessageHeightFromCache(markdown: string): number | null {
+export function estimateAssistantMessageHeightFromCache(
+ markdown: string,
+ contentMaxWidth: number,
+): number | null {
const parsed = assistantImageParseCache.get(markdown) ?? parseAssistantImageMarkdown(markdown);
if (parsed.sources.length === 0) {
return null;
@@ -189,7 +191,7 @@ export function estimateAssistantMessageHeightFromCache(markdown: string): numbe
.map((metadata) =>
Math.max(
ASSISTANT_IMAGE_MIN_HEIGHT,
- Math.round(ASSISTANT_IMAGE_ESTIMATE_WIDTH / metadata.aspectRatio),
+ Math.round((contentMaxWidth - ASSISTANT_IMAGE_INSET) / metadata.aspectRatio),
),
);
diff --git a/packages/app/src/utils/assistant-message-height-estimate.test.ts b/packages/app/src/utils/assistant-message-height-estimate.test.ts
index 7c6822723c..8df70fe588 100644
--- a/packages/app/src/utils/assistant-message-height-estimate.test.ts
+++ b/packages/app/src/utils/assistant-message-height-estimate.test.ts
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
+import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import {
clearAssistantMessageHeightEstimateCache,
estimateAssistantMessageHeightFromCache,
@@ -27,7 +28,21 @@ describe("assistant message height estimate", () => {
height: 41.1,
});
- expect(estimateAssistantMessageHeightFromCache("First paragraph\n\nSecond paragraph")).toBe(97);
+ expect(
+ estimateAssistantMessageHeightFromCache(
+ "First paragraph\n\nSecond paragraph",
+ DEFAULT_CONTENT_MAX_WIDTH,
+ ),
+ ).toBe(97);
+ });
+
+ it("reads block heights measured at the configured content width", () => {
+ setAssistantMarkdownBlockHeight({ block: "Wide paragraph", width: 1584, height: 20 });
+
+ expect(estimateAssistantMessageHeightFromCache("Wide paragraph", 1600)).toBe(44);
+ expect(
+ estimateAssistantMessageHeightFromCache("Wide paragraph", DEFAULT_CONTENT_MAX_WIDTH),
+ ).toBeNull();
});
it("falls back to image metadata when markdown blocks are not measured", () => {
@@ -41,6 +56,7 @@ describe("assistant message height estimate", () => {
expect(
estimateAssistantMessageHeightFromCache(
"Here is the screenshot\n\n",
+ DEFAULT_CONTENT_MAX_WIDTH,
),
).toBeGreaterThan(220);
});
diff --git a/packages/app/src/utils/assistant-message-height-estimate.ts b/packages/app/src/utils/assistant-message-height-estimate.ts
index 05d702fb41..638c961e01 100644
--- a/packages/app/src/utils/assistant-message-height-estimate.ts
+++ b/packages/app/src/utils/assistant-message-height-estimate.ts
@@ -1,9 +1,8 @@
-import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { estimateAssistantMessageHeightFromCache as estimateAssistantImageMessageHeightFromCache } from "@/utils/assistant-image-metadata";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
const ASSISTANT_MARKDOWN_BLOCK_HEIGHT_CACHE_LIMIT = 1000;
-const ASSISTANT_MARKDOWN_BLOCK_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 16;
+const ASSISTANT_MARKDOWN_BLOCK_INSET = 16;
const ASSISTANT_MESSAGE_VERTICAL_PADDING = 24;
const ASSISTANT_MARKDOWN_BLOCK_GAP = 12;
@@ -78,7 +77,10 @@ export function setAssistantMarkdownBlockHeight(input: {
return height;
}
-function estimateAssistantMarkdownBlockHeightFromCache(markdown: string): number | null {
+function estimateAssistantMarkdownBlockHeightFromCache(
+ markdown: string,
+ contentMaxWidth: number,
+): number | null {
const blocks = splitMarkdownBlocks(markdown);
if (blocks.length === 0) {
return null;
@@ -88,7 +90,7 @@ function estimateAssistantMarkdownBlockHeightFromCache(markdown: string): number
for (const block of blocks) {
const key = createMarkdownBlockHeightKey({
block,
- width: ASSISTANT_MARKDOWN_BLOCK_ESTIMATE_WIDTH,
+ width: contentMaxWidth - ASSISTANT_MARKDOWN_BLOCK_INSET,
});
const cachedHeight = key ? assistantMarkdownBlockHeightCache.get(key) : undefined;
if (cachedHeight === undefined) {
@@ -104,10 +106,13 @@ function estimateAssistantMarkdownBlockHeightFromCache(markdown: string): number
);
}
-export function estimateAssistantMessageHeightFromCache(markdown: string): number | null {
+export function estimateAssistantMessageHeightFromCache(
+ markdown: string,
+ contentMaxWidth: number,
+): number | null {
return (
- estimateAssistantMarkdownBlockHeightFromCache(markdown) ??
- estimateAssistantImageMessageHeightFromCache(markdown)
+ estimateAssistantMarkdownBlockHeightFromCache(markdown, contentMaxWidth) ??
+ estimateAssistantImageMessageHeightFromCache(markdown, contentMaxWidth)
);
}
diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts
index 4da4e5319e..d18a8cd48c 100644
--- a/packages/desktop/src/daemon/daemon-manager.test.ts
+++ b/packages/desktop/src/daemon/daemon-manager.test.ts
@@ -125,6 +125,36 @@ describe("daemon-manager commands", () => {
});
});
+ it("reports a stopped daemon without launching the CLI when no local daemon runs", async () => {
+ mkdirSync(mocks.paseoHome);
+ writeFileSync(path.join(mocks.paseoHome, "server-id"), "srv_existing\n");
+ mocks.runExternalCliJsonCommand.mockResolvedValue({
+ home: mocks.paseoHome,
+ pid: null,
+ startedAt: null,
+ listen: null,
+ hostname: null,
+ localDaemon: "stopped",
+ desktopManaged: false,
+ connectedDaemon: "not_probed",
+ });
+
+ const status = await createDaemonCommandHandlers().desktop_daemon_status();
+
+ expect(status).toMatchObject({ serverId: "", status: "stopped", pid: null });
+ expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled();
+ });
+
+ it("reports an errored daemon when the local daemon state cannot be read", async () => {
+ mkdirSync(mocks.paseoHome);
+ writeFileSync(path.join(mocks.paseoHome, "paseo.pid"), "garbage");
+
+ const status = await createDaemonCommandHandlers().desktop_daemon_status();
+
+ expect(status).toMatchObject({ serverId: "", status: "errored", pid: null });
+ expect(status.error).toBeTruthy();
+ });
+
it("returns a local credential only for its live managed daemon listen", async () => {
mkdirSync(mocks.paseoHome);
const token = "a".repeat(43);
diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts
index 8c1cf8dcc8..5f3f5394c6 100644
--- a/packages/desktop/src/daemon/daemon-manager.ts
+++ b/packages/desktop/src/daemon/daemon-manager.ts
@@ -224,6 +224,12 @@ export async function resolveDesktopDaemonStatus(): Promise
const home = getPaseoHome();
try {
+ // The app polls this while no local daemon runs. Answer that case in-process,
+ // since launching the CLI once per poll keeps spawning processes while idle.
+ if (!(await readDaemonInstance(home))) {
+ return statusFromDaemonProbe({ localDaemon: "stopped" }, home);
+ }
+
const payload = (await runExternalCliJsonCommand([
"daemon",
"status",
@@ -234,7 +240,7 @@ export async function resolveDesktopDaemonStatus(): Promise
return statusFromDaemonProbe(payload, home);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
- logDesktopDaemonLifecycle("resolveStatus CLI command failed", { error: errorMessage });
+ logDesktopDaemonLifecycle("resolveStatus failed", { error: errorMessage });
return {
serverId: "",
status: "errored",
diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts
index d39268b047..d9a0157172 100644
--- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts
+++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts
@@ -660,6 +660,7 @@ async function withCustomCodexProviderHome(
session: AgentSession;
readCaptured: () => CapturedFakeCodexRecord[];
}) => Promise,
+ providerPrompts: Record = {},
): Promise {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-provider-home-"));
const daemonCodexHome = path.join(tempDir, "daemon-codex-home");
@@ -676,6 +677,9 @@ async function withCustomCodexProviderHome(
path.join(providerCodexHome, "prompts", "probe-profile.md"),
"---\ndescription: Provider home prompt\n---\nfrom the provider home\n",
);
+ for (const [name, content] of Object.entries(providerPrompts)) {
+ writeFileSync(path.join(providerCodexHome, "prompts", `${name}.md`), content);
+ }
writeFileSync(
fakeAppServerPath,
`
@@ -753,11 +757,21 @@ async function listPromptCommandsFromCustomCodexHome(): Promise {
});
}
-async function runPromptFromCustomCodexHome(prompt: string): Promise {
+async function runPromptFromCustomCodexHome(
+ prompt: string,
+ providerPrompts: Record = {},
+): Promise {
return withCustomCodexProviderHome(async ({ session, readCaptured }) => {
await session.startTurn(prompt);
return readCaptured();
- });
+ }, providerPrompts);
+}
+
+async function expandCustomPrompt(template: string, args: string): Promise {
+ const records = await runPromptFromCustomCodexHome(`/prompts:template ${args}`, { template });
+ const turnStart = records.find((record) => record.method === "turn/start");
+ const params = turnStart?.params as { input: Array<{ text: string }> };
+ return params.input[0].text;
}
function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown {
@@ -2538,6 +2552,29 @@ describe("Codex app-server provider", () => {
expect(JSON.stringify(turnStart?.params)).toContain("from the provider home");
});
+ test("inserts custom prompt arguments literally", async () => {
+ await expect(
+ expandCustomPrompt("Implement this exact request: $ARGUMENTS", "Preserve $1 unchanged"),
+ ).resolves.toBe("Implement this exact request: Preserve $1 unchanged");
+ await expect(
+ expandCustomPrompt("Treat this as literal replacement text: $VALUE.", 'VALUE="$&"'),
+ ).resolves.toBe("Treat this as literal replacement text: $&.");
+ await expect(expandCustomPrompt("$1 then $2", '"$2" two')).resolves.toBe("$2 then two");
+ await expect(expandCustomPrompt("$A and $B", 'A="$B" B=b')).resolves.toBe("$B and b");
+ await expect(
+ expandCustomPrompt("$ARGUMENTS", "keep __CODEX_DOLLAR_PLACEHOLDER__ as typed"),
+ ).resolves.toBe("keep __CODEX_DOLLAR_PLACEHOLDER__ as typed");
+ });
+
+ test("expands custom prompt placeholders and escapes", async () => {
+ await expect(
+ expandCustomPrompt("$1/$2 $NAME costs $$5, all: $ARGUMENTS, $MISSING", "one NAME=n two"),
+ ).resolves.toBe("one/two n costs $5, all: one NAME=n two, $MISSING");
+ await expect(
+ expandCustomPrompt("$$ARGUMENTS $$1 $$NAME $9|$NAME_SUFFIX", "first NAME=value"),
+ ).resolves.toBe("$ARGUMENTS $1 $NAME |$NAME_SUFFIX");
+ });
+
test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => {
const commands = await listCommandsFromFakeCodex([
{
diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts
index cfeb8863ee..b621c62900 100644
--- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts
+++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts
@@ -786,25 +786,21 @@ function expandCodexCustomPrompt(template: string, args: string | undefined): st
positional.push(token);
}
- const dollarPlaceholder = "__CODEX_DOLLAR_PLACEHOLDER__";
- let out = template.split("$$").join(dollarPlaceholder);
+ // Match every placeholder in one pass, so inserted argument text is never expanded again.
+ const namedPlaceholders = Object.keys(named)
+ .sort((a, b) => b.length - a.length)
+ .map((key) => `${escapeRegExp(key)}\\b`);
+ const placeholder = new RegExp(
+ `\\$(${["\\$", "ARGUMENTS", "[1-9]", ...namedPlaceholders].join("|")})`,
+ "g",
+ );
- out = out.split("$ARGUMENTS").join(trimmedArgs);
-
- for (let i = 1; i <= 9; i += 1) {
- const value = positional[i - 1] ?? "";
- out = out.split(`$${i}`).join(value);
- }
-
- const namedKeys = Object.keys(named).sort((a, b) => b.length - a.length);
- for (const key of namedKeys) {
- const value = named[key] ?? "";
- const re = new RegExp(`\\$${escapeRegExp(key)}\\b`, "g");
- out = out.replace(re, value);
- }
-
- out = out.split(dollarPlaceholder).join("$");
- return out;
+ return template.replace(placeholder, (match, name: string) => {
+ if (name === "$") return "$";
+ if (name === "ARGUMENTS") return trimmedArgs;
+ if (/^[1-9]$/.test(name)) return positional[Number(name) - 1] ?? "";
+ return named[name] ?? match;
+ });
}
interface CodexMcpServerConfig {
diff --git a/packages/server/src/server/agent/providers/omp/agent.history-purpose.test.ts b/packages/server/src/server/agent/providers/omp/agent.history-purpose.test.ts
new file mode 100644
index 0000000000..ddd30a2590
--- /dev/null
+++ b/packages/server/src/server/agent/providers/omp/agent.history-purpose.test.ts
@@ -0,0 +1,64 @@
+import { mkdtemp, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { expect, test } from "vitest";
+
+import { createTestLogger } from "../../../../test-utils/test-logger.js";
+import type { AgentPersistenceHandle } from "../../agent-sdk-types.js";
+import { OmpAgentClient } from "./agent.js";
+import { FakeOmp } from "./test-utils/fake-omp.js";
+
+test("archived OMP history replays messages and todos without leaving a runtime", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "paseo-omp-history-purpose-"));
+ const sessionFile = join(directory, "session.jsonl");
+ const entries = [
+ { type: "session", id: "root", parentId: null },
+ {
+ type: "message",
+ id: "user-1",
+ parentId: "root",
+ message: { role: "user", content: "make a plan" },
+ },
+ {
+ type: "message",
+ id: "todo-1",
+ parentId: "user-1",
+ message: {
+ role: "toolResult",
+ toolName: "todo",
+ toolCallId: "call-1",
+ content: [{ type: "text", text: "one task" }],
+ details: {
+ phases: [{ name: "Tasks", tasks: [{ content: "Check history", status: "pending" }] }],
+ },
+ },
+ },
+ ];
+ await writeFile(sessionFile, entries.map((entry) => JSON.stringify(entry)).join("\n"));
+ const runtime = new FakeOmp();
+ const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
+ const handle: AgentPersistenceHandle = {
+ provider: "omp",
+ sessionId: "history-1",
+ nativeHandle: sessionFile,
+ metadata: { cwd: directory },
+ };
+
+ const session = await client.resumeSession(handle, undefined, undefined, { purpose: "history" });
+ const events = [];
+ try {
+ for await (const event of session.streamHistory()) events.push(event);
+ } finally {
+ await session.close();
+ }
+
+ expect(runtime.recordedLaunches).toHaveLength(0);
+ expect(events).toContainEqual({
+ type: "timeline",
+ provider: "omp",
+ item: {
+ type: "todo",
+ items: [{ text: "Check history", status: "pending", completed: false }],
+ },
+ });
+});
diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts
index 665dfee5ad..6858ed216d 100644
--- a/packages/server/src/server/agent/providers/omp/agent.test.ts
+++ b/packages/server/src/server/agent/providers/omp/agent.test.ts
@@ -1,9 +1,10 @@
import { describe, expect, test } from "vitest";
import { setImmediate as waitForImmediate } from "node:timers/promises";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
+import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
-import { join } from "node:path";
+import path, { join } from "node:path";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { PaseoToolCatalog } from "../../tools/types.js";
@@ -13,6 +14,9 @@ import type { OmpUsagePollScheduler } from "./usage-poller.js";
import { resolveOmpProviderParams } from "./provider-config.js";
import { OmpRuntimeEventSchema } from "./rpc-types.js";
import { OmpHarness } from "./test-utils/omp-harness.js";
+import { OmpAgentClient } from "./agent.js";
+import { FakeOmp } from "./test-utils/fake-omp.js";
+import { createTestLogger } from "../../../../test-utils/test-logger.js";
const TURN_LIFECYCLE_EVENTS = new Set([
"turn_started",
@@ -47,6 +51,26 @@ test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both",
rpcTimeoutMs: 90_000,
});
});
+
+test("OMP import uses the runtime's custom agent directory without a configured sessionDir", async () => {
+ const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-import-dir-"));
+ const agentDir = path.join(root, "agent");
+ const sessionFile = path.join(agentDir, "sessions", "project", "session.jsonl");
+ await mkdir(path.dirname(sessionFile), { recursive: true });
+ await writeFile(
+ sessionFile,
+ JSON.stringify({ type: "session", id: "custom-dir", cwd: root, timestamp: "2026-09-28" }),
+ );
+ const client = new OmpAgentClient({
+ logger: createTestLogger(),
+ runtime: new FakeOmp(),
+ runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } },
+ });
+
+ expect(await client.listImportableSessions({ cwd: root })).toEqual([
+ expect.objectContaining({ providerHandleId: sessionFile }),
+ ]);
+});
class ManualIdleScheduler implements OmpProviderIdleScheduler {
private readonly retries: Array<() => void> = [];
private readonly waiters: Array<{ count: number; resolve: () => void }> = [];
@@ -235,7 +259,7 @@ describe("OMP agent client and session", () => {
[expect.objectContaining({ name: "create_agent" })],
]);
expect(omp.capabilities()).toMatchObject({
- supportsMcpServers: false,
+ supportsMcpServers: true,
supportsNativePaseoTools: true,
});
});
@@ -247,6 +271,26 @@ describe("OMP agent client and session", () => {
expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "max"]));
});
+ test("launches with auto thinking when auto is selected", async () => {
+ const omp = new OmpHarness();
+ await omp.start({ thinkingOptionId: "auto" });
+
+ expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "auto"]));
+ });
+
+ test("keeps auto thinking when resuming a session", async () => {
+ const omp = new OmpHarness();
+ await omp.resume(
+ {
+ user: { id: "user-auto", text: "continue" },
+ assistant: { id: "assistant-auto", text: "ready" },
+ },
+ { thinkingOptionId: "auto" },
+ );
+
+ expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "auto"]));
+ });
+
test("launches with write approval mode", async () => {
const omp = new OmpHarness();
await omp.start({ modeId: "write" });
@@ -443,6 +487,182 @@ describe("OMP agent client and session", () => {
await expect(completion).resolves.toMatchObject({ finalText: "first done" });
});
+ test("fails a turn when the provider idle gate passes its deadline", async () => {
+ const scheduler = new ManualIdleScheduler();
+ const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 1 });
+ await omp.start();
+ const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", {
+ isStreaming: true,
+ isCompacting: false,
+ });
+ await scheduler.waitForWaits(1);
+ omp.runtime().emit({
+ type: "tool_execution_start",
+ toolCallId: "tool-at-deadline",
+ toolName: "bash",
+ args: { command: "sleep 30" },
+ });
+ expect(omp.runningToolCallIds()).toEqual(["tool-at-deadline"]);
+ await new Promise((resolve) => setTimeout(resolve, 2));
+ scheduler.retry();
+ await expect(completion).rejects.toThrow(/provider idle/i);
+ expect(omp.runningToolCallIds()).toEqual([]);
+ });
+
+ test("steers a running turn and correlates a template-expanded echo exactly once", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ const session = omp.requireSession();
+ const { turnId } = await session.startTurn("first", { clientMessageId: "client-first" });
+ const runtime = omp.runtime();
+ runtime.beginTurn();
+ runtime.acceptPrompt("first", "native-first");
+ await expect(
+ session.steerActiveTurn?.("expand template", {
+ expectedTurnId: turnId,
+ clientMessageId: "client-steer",
+ }),
+ ).resolves.toEqual({ status: "accepted" });
+ runtime.acceptPrompt("expanded prompt", "native-steer");
+ runtime.acceptPrompt("expanded prompt", "native-steer");
+ expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
+ expect.objectContaining({ messageId: "native-first", clientMessageId: "client-first" }),
+ expect.objectContaining({ messageId: "native-steer", clientMessageId: "client-steer" }),
+ ]);
+ });
+
+ test("reports a rejected steer as unavailable", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ const session = omp.requireSession();
+ const { turnId } = await session.startTurn("first");
+ omp.runtime().steerError = new Error("extension command cannot be steered");
+ await expect(
+ session.steerActiveTurn?.("extension input", { expectedTurnId: turnId }),
+ ).resolves.toEqual({ status: "unavailable" });
+ });
+
+ test("propagates an OMP steer transport timeout", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ const session = omp.requireSession();
+ const { turnId } = await session.startTurn("first");
+ const timeout = new Error(
+ "OMP RPC request timed out phase=steer elapsedMs=60000 timeoutMs=60000",
+ );
+ omp.runtime().steerError = timeout;
+ await expect(session.steerActiveTurn?.("second", { expectedTurnId: turnId })).rejects.toBe(
+ timeout,
+ );
+ });
+
+ test("shows OMP's fallback model without persisting it as the selected model", async () => {
+ const omp = new OmpHarness();
+ await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
+ const runtime = omp.runtime();
+ runtime.state = {
+ ...runtime.state,
+ model: { provider: "openrouter", id: "google/gemini-3.8-flash" },
+ };
+ runtime.emit({
+ type: "retry_fallback_applied",
+ from: "openrouter/google/gemini-3.8-flash",
+ to: "openrouter/other/model",
+ role: "primary",
+ });
+ runtime.state = { ...runtime.state, model: { provider: "openrouter", id: "other/model" } };
+ runtime.state = { ...runtime.state, fastModeEnabled: true, fastModeActive: false };
+ runtime.emit({ type: "model_changed" });
+ await waitForImmediate();
+ expect(omp.eventTypes()).toContain("model_changed");
+ expect((await omp.requireSession().getRuntimeInfo()).model).toBe("openrouter/other/model");
+ expect(omp.requireSession().describePersistence()?.metadata?.model).toBe(
+ "openrouter/google/gemini-3.8-flash",
+ );
+ expect(omp.requireSession().features).toEqual([
+ expect.objectContaining({
+ value: true,
+ description: expect.stringMatching(/does not apply/i),
+ }),
+ ]);
+ });
+
+ test("Fast stays selected when OMP says it is inactive for the current model", async () => {
+ const omp = new OmpHarness();
+ await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
+ const session = omp.requireSession();
+ omp.runtime().fastModeResult = { enabled: true, active: false };
+ await session.setFeature?.("fast_mode", true);
+ expect(omp.runtime().setFastModeRequests).toEqual([true]);
+ expect(session.features).toEqual([
+ expect.objectContaining({
+ id: "fast_mode",
+ value: true,
+ description: expect.stringMatching(/does not apply to this model/i),
+ tooltip: expect.stringMatching(/does not apply to this model/i),
+ }),
+ ]);
+ });
+
+ test("shows Fast only when OMP reports its state fields", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ const session = omp.requireSession();
+ const {
+ fastModeEnabled: _enabled,
+ fastModeActive: _active,
+ ...olderState
+ } = omp.runtime().state;
+ omp.runtime().state = olderState;
+ await session.getRuntimeInfo();
+ expect(session.features).toEqual([]);
+ await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(/unavailable/i);
+ omp.runtime().state = { ...olderState, fastModeEnabled: false, fastModeActive: false };
+ await session.getRuntimeInfo();
+ expect(session.features).toHaveLength(1);
+ });
+
+ test("switching model refreshes whether Fast applies", async () => {
+ const omp = new OmpHarness();
+ await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
+ const session = omp.requireSession();
+ omp.runtime().fastModeResult = { enabled: true, active: true };
+ await session.setFeature?.("fast_mode", true);
+ expect(session.features).toEqual([expect.objectContaining({ value: true })]);
+
+ omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" };
+ omp.runtime().queueStateReports([
+ {
+ ...omp.runtime().state,
+ model: omp.runtime().setModelResult,
+ fastModeEnabled: true,
+ fastModeActive: false,
+ },
+ ]);
+ await session.setModel?.("openrouter/other/model");
+ expect(session.features).toEqual([
+ expect.objectContaining({
+ value: true,
+ description: expect.stringMatching(/does not apply/i),
+ }),
+ ]);
+ });
+
+ test("restores Fast from the initial OMP state on create and resume", async () => {
+ const created = new OmpHarness();
+ await created.start({ featureValues: { fast_mode: true } });
+ expect(created.runtime().setFastModeRequests).toEqual([true]);
+ expect(created.runtime().getStateRequestCount).toBe(1);
+
+ const resumed = new OmpHarness();
+ await resumed.resume(
+ { user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } },
+ { featureValues: { fast_mode: true } },
+ );
+ expect(resumed.runtime().setFastModeRequests).toEqual([true]);
+ expect(resumed.runtime().getStateRequestCount).toBe(1);
+ });
+
test("does not complete on OMP's extension-notice agent_end", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -515,13 +735,162 @@ describe("OMP agent client and session", () => {
message: "Background job DocsSmokeTwo completed",
},
]);
- // Non-notice custom messages still fall through as assistant messages.
- expect(omp.timeline().filter((item) => item.type === "assistant_message")).toMatchObject([
- { text: "done" },
- { text: "plain custom status text" },
+ // Non-notice custom messages still fall through as assistant messages with
+ // their own id so the stream coalescer never glues them onto the open reply.
+ expect(omp.timeline().filter((item) => item.type === "assistant_message")).toEqual([
+ { type: "assistant_message", text: "done", messageId: "omp-assistant-1" },
+ { type: "assistant_message", text: "plain custom status text", messageId: "omp-custom-1" },
]);
});
+ test("shows a concise error for a failed tool while keeping shell output", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ omp.emit({
+ type: "tool_execution_start",
+ toolCallId: "failed-shell",
+ toolName: "bash",
+ args: { command: "false" },
+ });
+ omp.emit({
+ type: "tool_execution_end",
+ toolCallId: "failed-shell",
+ toolName: "bash",
+ result: {
+ content: [{ type: "text", text: "Command exited with code 1" }],
+ details: {},
+ isError: true,
+ exitCode: 1,
+ },
+ isError: true,
+ });
+ expect(omp.timeline().at(-1)).toMatchObject({
+ type: "tool_call",
+ status: "failed",
+ error: "Command exited with code 1",
+ detail: { type: "shell", command: "false", exitCode: 1 },
+ });
+ });
+
+ test("uses the exit message when a failed shell has no output", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ omp.emit({
+ type: "tool_execution_start",
+ toolCallId: "empty-shell",
+ toolName: "bash",
+ args: { command: "false" },
+ });
+ omp.emit({
+ type: "tool_execution_end",
+ toolCallId: "empty-shell",
+ toolName: "bash",
+ result: {
+ content: [
+ {
+ type: "text",
+ text: "(no output)\n\nWall time: 0.02 seconds\n\nCommand exited with code 1",
+ },
+ ],
+ isError: true,
+ },
+ isError: true,
+ });
+ expect(omp.timeline().at(-1)).toMatchObject({ error: "Command exited with code 1" });
+ });
+
+ test("does not duplicate the typed invocation for a live skill expansion", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ await omp.runPromptWithCustomMessage(
+ "hello",
+ {
+ role: "custom",
+ content: "[IMPORTANT] Full skill body",
+ customType: "skill-prompt",
+ attribution: "user",
+ details: { name: "commit" },
+ display: true,
+ id: "skill-1",
+ },
+ "done",
+ );
+ expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
+ { type: "user_message", text: "hello", messageId: "user-1" },
+ ]);
+ expect(omp.timeline()).not.toContainEqual(
+ expect.objectContaining({ text: "[IMPORTANT] Full skill body" }),
+ );
+ });
+
+ test.each([
+ { kind: "initial", priorPrompt: false },
+ { kind: "follow-up", priorPrompt: true },
+ ])("correlates a $kind skill invocation without a user echo", async ({ priorPrompt }) => {
+ const omp = new OmpHarness();
+ await omp.start();
+ if (priorPrompt) {
+ await omp.runPrompt("Reply OK", "OK");
+ }
+
+ const typed = "/skill:tldr Summarize: hi.";
+ const runtime = omp.runtime();
+ const promptStarted = runtime.nextPrompt();
+ const run = omp.requireSession().run(typed, { clientMessageId: "client-skill" });
+ await promptStarted;
+ runtime.beginTurn();
+ runtime.emit({
+ type: "message_end",
+ message: {
+ role: "custom",
+ content: "[IMPORTANT] Full skill body",
+ customType: "skill-prompt",
+ attribution: "user",
+ details: { name: "tldr", args: "Summarize: hi." },
+ display: true,
+ id: "skill-1",
+ },
+ });
+ runtime.streamAssistantText("done");
+ runtime.finishTurn();
+ await run;
+
+ expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
+ ...(priorPrompt ? [{ type: "user_message", text: "Reply OK", messageId: "user-1" }] : []),
+ { type: "user_message", text: typed, clientMessageId: "client-skill" },
+ ]);
+ });
+
+ test("marks an OMP web search details error as failed even without isError", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ omp.emit({
+ type: "tool_execution_start",
+ toolCallId: "search-failed",
+ toolName: "web_search",
+ args: { query: "Paseo" },
+ });
+ omp.emit({
+ type: "tool_execution_end",
+ toolCallId: "search-failed",
+ toolName: "web_search",
+ result: {
+ content: [{ type: "text", text: "Error: All web search providers failed" }],
+ details: {
+ response: { provider: "mojeek", sources: [] },
+ error: "All web search providers failed",
+ },
+ },
+ isError: false,
+ });
+ expect(omp.timeline().at(-1)).toMatchObject({
+ type: "tool_call",
+ status: "failed",
+ error: "All web search providers failed",
+ detail: { type: "search", query: "Paseo" },
+ });
+ });
+
test("does not complete a queued model turn from OMP's local-only hint", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -540,6 +909,28 @@ describe("OMP agent client and session", () => {
expect(omp.completedTurnCount()).toBe(1);
});
+ test("completes a no-turn notify with one notification and no assistant text", async () => {
+ const scheduler = new ManualNoTurnScheduler();
+ const omp = new OmpHarness({ noTurnScheduler: scheduler });
+ await omp.start();
+ const prompt = await omp.startPromptWithFalseLocalOnlyResult("/autoresearch off");
+ omp.emit({
+ type: "extension_ui_request",
+ id: "local-notify",
+ method: "notify",
+ message: "Autoresearch mode disabled",
+ notifyType: "info",
+ });
+
+ scheduler.settle();
+ await expect(prompt.completion).resolves.toMatchObject({ finalText: "" });
+ expect(omp.completedTurnCount()).toBe(1);
+ expect(omp.timeline().filter((item) => item.type === "notification")).toEqual([
+ { type: "notification", level: "info", message: "Autoresearch mode disabled" },
+ ]);
+ expect(omp.timeline().filter((item) => item.type === "assistant_message")).toEqual([]);
+ });
+
test("waits for a delayed queued model turn after OMP's local-only result", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -659,6 +1050,33 @@ describe("OMP agent client and session", () => {
]);
});
+ test("shows OMP notifications during a turn and while idle with their levels", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ await omp.startTurn("work");
+ omp.runtime().beginTurn();
+ omp.emit({
+ type: "extension_ui_request",
+ id: "n1",
+ method: "notify",
+ message: "Working",
+ notifyType: "warning",
+ });
+ omp.runtime().finishTurn();
+ omp.emit({
+ type: "extension_ui_request",
+ id: "n2",
+ method: "notify",
+ message: "Done",
+ notifyType: "info",
+ });
+ expect(omp.timeline().filter((item) => item.type === "notification")).toEqual([
+ { type: "notification", level: "warning", message: "Working" },
+ { type: "notification", level: "info", message: "Done" },
+ ]);
+ expect(omp.timeline().filter((item) => item.type === "assistant_message")).toEqual([]);
+ });
+
test("maps legacy select options without descriptions and preserves ordinary responses", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -724,41 +1142,6 @@ describe("OMP agent client and session", () => {
]);
});
- test("preserves combined selection descriptions and freeform sentinel behavior", async () => {
- const omp = new OmpHarness();
- await omp.start();
- omp.emit({
- type: "tool_execution_start",
- toolCallId: "ask-user-1",
- toolName: "ask_user",
- args: { allowComment: true, allowFreeform: true, allowMultiple: false },
- });
- omp.emit({
- type: "extension_ui_request",
- id: "select-combined",
- method: "select",
- title: "Choose",
- options: ["First", "✏️ Type custom response..."],
- optionDetails: [{ description: "First detail" }, { description: "ignored" }],
- });
-
- const combinedInput = omp.pendingPermissions()[0]?.input;
- expect(combinedInput?.questions?.[0]?.options).toStrictEqual([
- { label: "First", description: "First detail" },
- ]);
- expect(combinedInput).toMatchObject({
- questions: [{ allowOther: true }, { header: "Comment" }],
- });
- await omp.respondToPermission("select-combined", {
- behavior: "allow",
- updatedInput: { answers: { Response: "custom", Comment: "note" } },
- });
- expect(omp.extensionUiResponses()).toContainEqual({
- id: "select-combined",
- response: { value: "✏️ Type custom response..." },
- });
- });
-
test("exposes OMP modes and commands through the domain session", async () => {
const omp = new OmpHarness();
omp.queueCommands([{ name: "review", description: "Review changes", source: "skill" }]);
@@ -775,10 +1158,99 @@ describe("OMP agent client and session", () => {
expect.objectContaining({ name: "review", kind: "skill" }),
]),
);
+ await expect(omp.setMode("ask")).resolves.toBeUndefined();
+ await expect(omp.currentMode()).resolves.toBe("ask");
+ expect(omp.runtimeLaunches()[1]?.argv).toContain("--approval-mode");
+ expect(omp.runtimeLaunches()[1]?.argv).toContain("always-ask");
+ });
+
+ test("restarts the same conversation after an idle process exit", async () => {
+ const omp = new OmpHarness();
+ await omp.start({ modeId: "full" });
+ const previousId = omp.runtime().state.sessionId;
+ omp.processExit("OMP RPC process exited with code null and signal SIGKILL\nBun crashed");
+ expect(omp.turnFailures()).toEqual([
+ "OMP RPC process exited with code null and signal SIGKILL\nBun crashed",
+ ]);
+
+ await omp.startTurn("remember the conversation");
+ expect(omp.runtimeLaunches()).toHaveLength(2);
+ expect(omp.runtimeLaunches()[1]?.session).toBe("/tmp/omp-session");
+ expect(omp.runtime().state.sessionId).toBe(previousId);
+ expect(omp.threadStartedSessionIds()).toEqual([]);
+ expect(omp.runtime().prompts).toEqual([
+ { message: "remember the conversation", imageCount: 0 },
+ ]);
+ });
+
+ test("reports a mid-turn process exit and resumes on the following prompt", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ await omp.requireStartTurn("sleep 30");
+ omp.runtime().beginTurn();
+ omp.processExit("OMP RPC process exited with code 137 and signal null\nOOM");
+ expect(omp.turnFailures()).toEqual([
+ "OMP RPC process exited with code 137 and signal null\nOOM",
+ ]);
+ await omp.startTurn("continue");
+ expect(omp.runtimeLaunches()).toHaveLength(2);
+ expect(omp.runtime().prompts).toEqual([{ message: "continue", imageCount: 0 }]);
+ });
+
+ test("reports an immediate relaunch failure without retrying in a loop", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ omp.processExit("OMP RPC process exited with code null and signal SIGKILL");
+ omp.failNextStart(new Error("Bun failed during startup"));
+ await omp.startTurn("continue");
+ expect(omp.turnFailures()).toEqual([
+ "OMP RPC process exited with code null and signal SIGKILL",
+ "Bun failed during startup",
+ ]);
+ expect(omp.runtimeLaunches()).toHaveLength(1);
+ });
+
+ test("closes a replacement process if the session closes during relaunch", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ omp.processExit("OMP RPC process exited with code 1 and signal null");
+ const turn = omp.startTurnDetached("continue");
+ await omp.close();
+ await turn;
+ await waitForImmediate();
+ expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true);
+ });
+
+ test("announces a fresh native session when an internal agent recovers", async () => {
+ const omp = new OmpHarness();
+ await omp.start({ internal: true });
+ const previousId = omp.runtime().state.sessionId;
+ omp.processExit("OMP RPC process exited with code 1 and signal null");
+ await omp.startTurn("continue");
+ expect(omp.runtimeLaunches()[1]?.argv).toContain("--no-session");
+ expect(omp.runtime().state.sessionId).not.toBe(previousId);
+ expect(omp.threadStartedSessionIds()).toEqual([omp.runtime().state.sessionId]);
+ });
+
+ test("leaves the current approval mode in place when relaunch fails", async () => {
+ const omp = new OmpHarness();
+ await omp.start({ modeId: "full" });
+ omp.failNextStart(new Error("OMP launch failed"));
+ await expect(omp.setMode("ask")).rejects.toThrow("OMP launch failed");
+ expect(await omp.currentMode()).toBe("full");
+ expect(omp.runtimeLaunches()).toHaveLength(1);
+ });
+
+ test("rejects a running approval-mode change until the turn ends", async () => {
+ const omp = new OmpHarness();
+ await omp.start({ modeId: "full" });
+ await omp.requireStartTurn("work");
await expect(omp.setMode("ask")).resolves.toEqual({
type: "warning",
- message: "Start a new OMP session to change approval mode",
+ message: "Change approval mode once the current turn ends",
});
+ expect(omp.runtimeLaunches()).toHaveLength(1);
+ expect(await omp.currentMode()).toBe("full");
});
test("rewinds natively, interrupts, and shuts down", async () => {
diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts
index 5555fa130e..8e931b55fd 100644
--- a/packages/server/src/server/agent/providers/omp/agent.ts
+++ b/packages/server/src/server/agent/providers/omp/agent.ts
@@ -19,6 +19,7 @@ import {
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentProvider,
+ type AgentResumeSessionOptions,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
@@ -34,6 +35,8 @@ import {
type ListImportableSessionsOptions,
type ProviderCatalog,
type ProviderRefreshContext,
+ type SteerActiveTurnOptions,
+ type SteerResult,
type ToolCallDetail,
} from "../../agent-sdk-types.js";
import type { PaseoToolCatalog } from "../../tools/types.js";
@@ -67,13 +70,18 @@ import {
} from "./provider-config.js";
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
-import { shouldDisplayOmpCustomMessage } from "./custom-message.js";
+import {
+ ompCustomMessageId,
+ ompSkillPromptUserText,
+ shouldDisplayOmpCustomMessage,
+} from "./custom-message.js";
import { getUserMessageText } from "./message-history.js";
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
import { materializeProviderImage } from "../provider-image-output.js";
import { OmpCliRuntime } from "./cli-runtime.js";
import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js";
import type { OmpRuntime, OmpRuntimeSession, OmpStartSessionInput } from "./runtime.js";
+import { OmpMcpBridge, type OmpBridgedToolIdentity } from "./mcp-bridge.js";
import type {
OmpAgentSessionEvent,
OmpAgentMessage,
@@ -86,21 +94,20 @@ import type {
import {
parseToolArgs,
parseToolResult,
+ toolFailureMessage,
+ isOmpToolFailure,
resolveToolCallName,
type OmpToolResult,
type OmpTrackedToolCall,
} from "./tool-call-detail.js";
import { mapOmpAvailableCommandsUpdate, mapOmpRuntimeSlashCommands } from "./commands.js";
-import { streamOmpHistory } from "./history.js";
+import { readOmpHistoryTodoState, streamOmpHistory } from "./history.js";
import { mapOmpTodoReminderEvent, mapOmpTodoState, mapOmpTodoToolResult } from "./todo-mapper.js";
import { mapOmpRuntimeEventToTimelineItem } from "./event-mapper.js";
import { mapOmpAdvisorMessageToToolCall } from "./advisor-message.js";
-import {
- clearOmpHostToolState,
- handleOmpHostToolRuntimeEvent,
- setOmpHostTools,
-} from "./host-tools.js";
+import { handleOmpHostToolRuntimeEvent, OmpHostToolRouter } from "./host-tools.js";
import { OmpSubagentIndex } from "./subagent-index.js";
+import { OmpQuestionUi } from "./question-ui.js";
import { mapOmpToolDetail } from "./tool-call-mapper.js";
import { OmpUsagePoller, type OmpUsagePollScheduler } from "./usage-poller.js";
import {
@@ -111,17 +118,12 @@ import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js";
import { resolveOmpUsageReference } from "./usage-reference.js";
const OMP_PROVIDER = "omp";
-const QUESTION_RESPONSE_HEADER = "Response";
-const QUESTION_COMMENT_HEADER = "Comment";
-const OMP_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
-const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment";
-
const OMP_CORE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsSessionListing: true,
supportsDynamicModes: true,
- supportsMcpServers: false,
+ supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: true,
@@ -138,6 +140,7 @@ export interface OmpAgentClientOptions {
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
+ providerIdleDeadlineMs?: number;
}
export interface OmpProviderIdleScheduler {
@@ -153,6 +156,8 @@ export interface OmpNoTurnScheduler {
// v0.2.0-beta.1; remove after January 20, 2027 once the minimum OMP version
// guarantees prompt_result waits for queued extension work.
const OMP_NO_TURN_SETTLE_MS = 5_000;
+const OMP_PROVIDER_IDLE_DEADLINE_MS = 600_000;
+const OMP_FAST_INACTIVE_MESSAGE = "Fast is enabled but does not apply to this model.";
interface OmpPromptPayload {
text: string;
@@ -170,6 +175,7 @@ interface OmpPersistenceMetadata {
thinkingOptionId?: string;
modeId?: string;
systemPrompt?: string;
+ bridgedTools: Map;
}
interface StartTurnResult {
@@ -178,6 +184,14 @@ interface StartTurnResult {
interface OmpAgentSessionOptions {
runtimeSession: OmpRuntimeSession;
+ hostTools?: OmpHostToolRouter;
+ restartRuntime: (
+ sessionFile: string | null,
+ modeId: string,
+ ) => Promise<{
+ runtimeSession: OmpRuntimeSession;
+ hostTools?: OmpHostToolRouter;
+ }>;
config: AgentSessionConfig;
initialState: OmpSessionState;
currentModeId?: string | null;
@@ -186,7 +200,7 @@ interface OmpAgentSessionOptions {
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
- paseoTools?: PaseoToolCatalog;
+ providerIdleDeadlineMs?: number;
usageEnv?: NodeJS.ProcessEnv;
/**
* When false (resumed sessions), replayed session events are dropped until
@@ -220,24 +234,6 @@ interface OmpResumeConfig {
config: AgentSessionConfig;
}
-interface ActiveAskUserDialog {
- allowComment: boolean;
- allowFreeform: boolean;
- allowMultiple: boolean;
-}
-
-interface PendingCombinedAskUserResponse {
- comment: string;
- freeform: string | null;
-}
-
-interface ExtensionUiMappingOptions {
- provider?: AgentProvider;
- label?: string;
- combineOptionalComment?: boolean;
- allowFreeform?: boolean;
-}
-
interface OmpSlashCommandInvocation {
commandName: string;
args?: string;
@@ -283,7 +279,8 @@ function isOmpThinkingLevel(value: string | null | undefined): value is OmpThink
value === "medium" ||
value === "high" ||
value === "xhigh" ||
- value === "max"
+ value === "max" ||
+ value === "auto"
);
}
@@ -390,9 +387,20 @@ function parseModelReference(modelId: string | null): OmpModelReference | null {
function parsePersistenceMetadata(metadata: AgentMetadata | undefined): OmpPersistenceMetadata {
if (!metadata) {
- return {};
+ return { bridgedTools: new Map() };
+ }
+ const bridgedTools = new Map();
+ if (Array.isArray(metadata.bridgedTools)) {
+ for (const entry of metadata.bridgedTools) {
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") continue;
+ const identity = entry[1];
+ if (!identity || typeof identity !== "object" || Array.isArray(identity)) continue;
+ if (typeof identity.server !== "string" || typeof identity.tool !== "string") continue;
+ bridgedTools.set(entry[0], { server: identity.server, tool: identity.tool });
+ }
}
return {
+ bridgedTools,
...(typeof metadata.cwd === "string" ? { cwd: metadata.cwd } : {}),
...(typeof metadata.model === "string" ? { model: metadata.model } : {}),
...(typeof metadata.thinkingOptionId === "string"
@@ -464,7 +472,7 @@ function readNativeMessageId(
function withOmpCapabilities(): AgentCapabilityFlags {
return {
...OMP_CORE_CAPABILITIES,
- supportsMcpServers: false,
+ supportsMcpServers: true,
supportsNativePaseoTools: true,
};
}
@@ -477,6 +485,20 @@ function isOmpRequestAbortError(error: unknown): boolean {
return /\brequest was aborted\b|\babort(ed)?\b/i.test(toDiagnosticErrorMessage(error));
}
+function isOmpSteerTransportFailure(error: unknown): boolean {
+ // JsonlRpcProcess currently uses plain Error for both RPC rejections and transport failures.
+ // Only its own timeout/closed-process messages are transport failures; OMP rejections are unavailable.
+ const message = toDiagnosticErrorMessage(error);
+ return (
+ /^OMP RPC request timed out phase=steer\b/.test(message) ||
+ /^OMP RPC process (?:is closed|exited\b)/.test(message)
+ );
+}
+
+function hasOmpFastMode(state: OmpSessionState): boolean {
+ return typeof state.fastModeEnabled === "boolean" && typeof state.fastModeActive === "boolean";
+}
+
function resolveThinkingOptionId(
cachedThinkingOptionId: string | null,
sessionThinkingLevel: OmpThinkingLevel | undefined,
@@ -533,134 +555,10 @@ function isOmpAbortedTerminalResponse(messages: OmpAgentMessage[]): boolean {
return latestAssistant?.stopReason?.toLowerCase() === "aborted";
}
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
-function optionalBoolean(value: unknown): boolean | undefined {
- return typeof value === "boolean" ? value : undefined;
-}
-
-function readActiveAskUserDialog(toolName: string, args: unknown): ActiveAskUserDialog | null {
- if (toolName !== "ask_user" || !isRecord(args)) {
- return null;
- }
- return {
- allowComment: optionalBoolean(args.allowComment) ?? false,
- allowFreeform: optionalBoolean(args.allowFreeform) ?? true,
- allowMultiple: optionalBoolean(args.allowMultiple) ?? false,
- };
-}
-
-function isOptionalInputPlaceholder(placeholder: string | undefined): boolean {
- return /\boptional\b|\bskip\b/i.test(placeholder ?? "");
-}
-
-function getInputQuestionTitle(title: string | undefined, placeholder: string | undefined): string {
- if (!isOptionalInputPlaceholder(placeholder)) {
- return title ?? "Enter a value";
- }
- if (/\bcomment\b/i.test(`${title ?? ""}\n${placeholder ?? ""}`)) {
- return "Optional comment";
- }
- return "Optional response";
-}
-
-interface OmpSelectOption {
- label: string;
- description?: string;
-}
-
-function readSelectOptions(options: unknown, optionDetails: unknown): OmpSelectOption[] {
- const labels = readStringArray(options);
- const details = Array.isArray(optionDetails) ? optionDetails : [];
- return labels.map((label, index) => {
- const detail = details[index];
- const description =
- isRecord(detail) && typeof detail.description === "string" && detail.description.trim() !== ""
- ? detail.description
- : undefined;
- return description === undefined ? { label } : { label, description };
- });
-}
-
-function readStringArray(value: unknown): string[] {
- return Array.isArray(value)
- ? value.filter((item): item is string => typeof item === "string")
- : [];
-}
-
-function isOmpAskUserFreeformOption(option: string): boolean {
- return option === OMP_ASK_USER_FREEFORM_SENTINEL;
-}
-
-function mapExtensionUiRequestToPermission(
- event: Extract,
- options: ExtensionUiMappingOptions = {},
-): AgentPermissionRequest | null {
- const provider = options.provider ?? OMP_PROVIDER;
- const label = options.label ?? "OMP";
- switch (event.method) {
- case "select": {
- const selectOptions = readSelectOptions(event.options, event.optionDetails);
- if (options.combineOptionalComment) {
- return buildCombinedAskUserQuestionPermission(event, {
- provider,
- label,
- question: optionalString(event.title) ?? "Select an option",
- options: selectOptions,
- allowFreeform: options.allowFreeform === true,
- });
- }
- return buildExtensionUiQuestionPermission(event, {
- provider,
- label,
- question: optionalString(event.title) ?? "Select an option",
- options: selectOptions,
- multiSelect: false,
- });
- }
- case "input": {
- const placeholder = optionalString(event.placeholder);
- const title = optionalString(event.title);
- const allowEmpty = isOptionalInputPlaceholder(placeholder);
- return buildExtensionUiQuestionPermission(event, {
- provider,
- label,
- question: getInputQuestionTitle(title, placeholder),
- options: [],
- multiSelect: false,
- ...(placeholder ? { placeholder } : {}),
- ...(allowEmpty ? { allowEmpty: true, dismissLabel: "Skip" } : {}),
- });
- }
- case "editor":
- return buildExtensionUiQuestionPermission(event, {
- provider,
- label,
- question: optionalString(event.title) ?? "Edit text",
- options: [],
- multiSelect: false,
- });
- case "confirm":
- return buildExtensionUiQuestionPermission(event, {
- provider,
- label,
- question: [optionalString(event.title), optionalString(event.message)]
- .filter(Boolean)
- .join("\n\n"),
- options: [{ label: "Yes" }, { label: "No" }],
- multiSelect: false,
- });
- default:
- return null;
- }
-}
-
function isExtensionUiRequestEvent(
event: OmpRuntimeEvent,
): event is Extract {
@@ -692,172 +590,6 @@ function isOmpAgentSessionEvent(event: OmpRuntimeEvent): event is OmpAgentSessio
}
}
-function buildExtensionUiQuestionPermission(
- event: Extract,
- input: {
- provider: AgentProvider;
- label: string;
- question: string;
- options: OmpSelectOption[];
- multiSelect: boolean;
- placeholder?: string;
- allowEmpty?: boolean;
- dismissLabel?: string;
- },
-): AgentPermissionRequest {
- return {
- id: event.id,
- provider: input.provider,
- name: `${input.label} ${event.method}`,
- kind: "question",
- title: input.question,
- input: {
- questions: [
- {
- question: input.question,
- header: QUESTION_RESPONSE_HEADER,
- options: input.options.map((option) => ({
- label: option.label,
- ...(option.description === undefined ? {} : { description: option.description }),
- })),
- multiSelect: input.multiSelect,
- ...(input.placeholder ? { placeholder: input.placeholder } : {}),
- ...(input.allowEmpty ? { allowEmpty: true } : {}),
- ...(input.dismissLabel ? { dismissLabel: input.dismissLabel } : {}),
- },
- ],
- },
- metadata: {
- extensionUiMethod: event.method,
- answerHeader: QUESTION_RESPONSE_HEADER,
- },
- };
-}
-
-function buildCombinedAskUserQuestionPermission(
- event: Extract,
- input: {
- provider: AgentProvider;
- label: string;
- question: string;
- options: OmpSelectOption[];
- allowFreeform: boolean;
- },
-): AgentPermissionRequest {
- const visibleOptions = input.options.filter(
- (option) => !isOmpAskUserFreeformOption(option.label),
- );
- const allowOther = input.allowFreeform || visibleOptions.length !== input.options.length;
- return {
- id: event.id,
- provider: input.provider,
- name: `${input.label} ask_user`,
- kind: "question",
- title: input.question,
- input: {
- questions: [
- {
- question: input.question,
- header: QUESTION_RESPONSE_HEADER,
- options: visibleOptions.map((option) => ({
- label: option.label,
- ...(option.description === undefined ? {} : { description: option.description }),
- })),
- multiSelect: false,
- ...(allowOther ? { allowOther: true } : {}),
- },
- {
- question: "Optional comment",
- header: QUESTION_COMMENT_HEADER,
- options: [],
- multiSelect: false,
- placeholder: "Optional comment (press Enter to skip)...",
- allowEmpty: true,
- },
- ],
- },
- metadata: {
- extensionUiMethod: event.method,
- answerHeader: QUESTION_RESPONSE_HEADER,
- commentHeader: QUESTION_COMMENT_HEADER,
- combinedAskUser: COMBINED_ASK_USER_METADATA,
- selectOptions: visibleOptions.map((option) => option.label),
- ...(allowOther ? { freeformSentinel: OMP_ASK_USER_FREEFORM_SENTINEL } : {}),
- },
- };
-}
-
-function permissionAnswer(input: AgentMetadata | undefined, header: string): string | null {
- const answers = isRecord(input?.answers) ? input.answers : null;
- if (!answers) {
- return null;
- }
- const answer = answers[header];
- return typeof answer === "string" ? answer : null;
-}
-
-function firstPermissionAnswer(input: AgentMetadata | undefined): string | null {
- const answers = isRecord(input?.answers) ? input.answers : null;
- if (!answers) {
- return null;
- }
- const first = Object.values(answers).find((value) => typeof value === "string");
- return typeof first === "string" ? first : null;
-}
-
-function isCombinedAskUserPermission(request: AgentPermissionRequest): boolean {
- return request.metadata?.combinedAskUser === COMBINED_ASK_USER_METADATA;
-}
-
-function buildCombinedAskUserSelectionResponse(
- request: AgentPermissionRequest,
- response: AgentPermissionResponse,
-): {
- uiResponse: { value?: string; cancelled?: boolean };
- pendingResponse: PendingCombinedAskUserResponse | null;
-} {
- if (response.behavior === "deny") {
- return { uiResponse: { cancelled: true }, pendingResponse: null };
- }
-
- const answer = permissionAnswer(response.updatedInput, QUESTION_RESPONSE_HEADER);
- if (answer === null) {
- return { uiResponse: { cancelled: true }, pendingResponse: null };
- }
-
- const selectOptions = readStringArray(request.metadata?.selectOptions);
- const freeformSentinel = optionalString(request.metadata?.freeformSentinel);
- const isFreeform = Boolean(freeformSentinel) && !selectOptions.includes(answer);
- const comment = permissionAnswer(response.updatedInput, QUESTION_COMMENT_HEADER) ?? "";
- return {
- uiResponse: { value: isFreeform ? freeformSentinel : answer },
- pendingResponse: {
- comment,
- freeform: isFreeform ? answer : null,
- },
- };
-}
-
-function buildExtensionUiResponse(
- request: AgentPermissionRequest,
- response: AgentPermissionResponse,
-): { value?: string; confirmed?: boolean; cancelled?: boolean } {
- if (response.behavior === "deny") {
- return { cancelled: true };
- }
-
- const method = optionalString(request.metadata?.extensionUiMethod);
- const answer = firstPermissionAnswer(response.updatedInput);
- if (answer === null) {
- return { cancelled: true };
- }
-
- if (method === "confirm") {
- return { confirmed: /^yes$/i.test(answer.trim()) };
- }
- return { value: answer };
-}
-
function createRuntime(
logger: Logger,
runtimeSettings: ProviderRuntimeSettings | undefined,
@@ -873,6 +605,85 @@ function createRuntime(
});
}
+class OmpHistorySession implements AgentSession {
+ readonly provider: AgentProvider;
+ readonly capabilities: AgentCapabilityFlags = withOmpCapabilities();
+
+ constructor(
+ private readonly handle: AgentPersistenceHandle,
+ private readonly config: OmpResumeConfig,
+ private readonly sessionFile: string,
+ provider: AgentProvider,
+ private readonly bridgedTools: ReadonlyMap,
+ ) {
+ this.provider = provider;
+ }
+
+ get id(): string | null {
+ return this.handle.sessionId;
+ }
+
+ async run(): Promise {
+ throw new Error("OMP history session cannot start a turn");
+ }
+
+ async startTurn(): Promise<{ turnId: string }> {
+ throw new Error("OMP history session cannot start a turn");
+ }
+
+ subscribe(): () => void {
+ return () => undefined;
+ }
+
+ async *streamHistory(): AsyncGenerator {
+ yield* streamOmpHistory({
+ sessionFile: this.sessionFile,
+ provider: this.provider,
+ bridgedTools: this.bridgedTools,
+ });
+ const todo = await readOmpHistoryTodoState(this.sessionFile);
+ if (todo) yield { type: "timeline", provider: this.provider, item: todo };
+ }
+
+ async getRuntimeInfo(): Promise {
+ return {
+ provider: this.provider,
+ sessionId: this.handle.sessionId,
+ model: this.config.model ?? null,
+ thinkingOptionId: this.config.thinkingOptionId ?? null,
+ modeId: this.config.modeId ?? null,
+ };
+ }
+
+ async getAvailableModes(): Promise {
+ return [...OMP_MODES];
+ }
+
+ async getCurrentMode(): Promise {
+ return this.config.modeId ?? null;
+ }
+
+ async setMode(): Promise {
+ throw new Error("OMP history session cannot change mode");
+ }
+
+ getPendingPermissions(): AgentPermissionRequest[] {
+ return [];
+ }
+
+ async respondToPermission(): Promise {
+ throw new Error("OMP history session has no pending permissions");
+ }
+
+ describePersistence(): AgentPersistenceHandle {
+ return this.handle;
+ }
+
+ async interrupt(): Promise {}
+
+ async close(): Promise {}
+}
+
export class OmpAgentSession implements AgentSession {
readonly provider: AgentProvider = OMP_PROVIDER;
readonly capabilities: AgentCapabilityFlags = withOmpCapabilities();
@@ -880,10 +691,10 @@ export class OmpAgentSession implements AgentSession {
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly activeToolCalls = new Map();
private readonly pendingExtensionUiRequests = new Map();
- private activeAskUserDialog: ActiveAskUserDialog | null = null;
- private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null;
+ private readonly questionUi = new OmpQuestionUi();
private activeTurnId: string | null = null;
- private activeClientMessageId: string | null = null;
+ private readonly pendingClientMessages: Array<{ clientMessageId: string | null; text: string }> =
+ [];
private activeAssistantMessageId: string | null = null;
private activeTurnTerminalAssistantMessage: OmpAgentMessage | null = null;
private activeTurnStarted = false;
@@ -903,24 +714,31 @@ export class OmpAgentSession implements AgentSession {
private readonly subagentCardTracker: OmpSubagentCardTracker;
private lastTodoItem: Extract | null = null;
private state: OmpSessionState;
- private readonly currentModeId: string | null;
+ private currentModeId: string | null;
+ private runtimeDead = false;
+ private replacingRuntime: Promise | null = null;
+ private unsubscribeRuntime: (() => void) | null = null;
private readonly providerIdleScheduler: OmpProviderIdleScheduler;
+ private readonly providerIdleDeadlineMs: number;
private readonly noTurnScheduler: OmpNoTurnScheduler;
private readonly usagePoller: OmpUsagePoller;
private closed = false;
private live: boolean;
private readonly emittedUserMessageIds = new Set();
+ private customMessageIndex = 0;
constructor(options: OmpAgentSessionOptions) {
this.runtimeSession = options.runtimeSession;
+ this.hostTools = options.hostTools;
+ this.restartRuntime = options.restartRuntime;
this.config = options.config;
this.state = options.initialState;
this.currentModeId = options.currentModeId ?? null;
this.logger = options.logger;
- this.paseoTools = options.paseoTools;
this.usageEnv = options.usageEnv ?? process.env;
this.live = options.live ?? true;
this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler();
+ this.providerIdleDeadlineMs = options.providerIdleDeadlineMs ?? OMP_PROVIDER_IDLE_DEADLINE_MS;
this.noTurnScheduler = options.noTurnScheduler ?? createOmpNoTurnScheduler();
this.usagePoller = new OmpUsagePoller({
scheduler: options.usagePollScheduler,
@@ -944,29 +762,29 @@ export class OmpAgentSession implements AgentSession {
normalizeOmpThinkingOption(options.config.thinkingOptionId) ??
this.state.thinkingLevel ??
null;
- this.runtimeSession.onEvent((event) => {
- this.handleRuntimeEvent(event);
+ this.attachRuntime(this.runtimeSession);
+ }
+
+ private attachRuntime(runtime: OmpRuntimeSession): void {
+ this.unsubscribeRuntime = runtime.onEvent((event) => {
+ if (!this.closed && runtime === this.runtimeSession) this.handleRuntimeEvent(event);
});
- void this.runtimeSession.setSubagentSubscription("events").catch((eventsError: unknown) => {
+ void runtime.setSubagentSubscription("events").catch((eventsError: unknown) => {
this.logger.debug(
{ err: eventsError },
"OMP subagent event subscription unavailable; falling back to progress",
);
- void this.runtimeSession
- .setSubagentSubscription("progress")
- .catch((progressError: unknown) => {
- this.logger.debug(
- { err: progressError },
- "OMP subagent progress subscription unavailable",
- );
- });
+ void runtime.setSubagentSubscription("progress").catch((progressError: unknown) => {
+ this.logger.debug({ err: progressError }, "OMP subagent progress subscription unavailable");
+ });
});
}
- private readonly runtimeSession: OmpRuntimeSession;
+ private runtimeSession: OmpRuntimeSession;
+ private hostTools?: OmpHostToolRouter;
+ private readonly restartRuntime: OmpAgentSessionOptions["restartRuntime"];
private readonly config: AgentSessionConfig;
private readonly logger: Logger;
- private readonly paseoTools?: PaseoToolCatalog;
private readonly usageEnv: NodeJS.ProcessEnv;
async getUsageReference() {
@@ -978,6 +796,35 @@ export class OmpAgentSession implements AgentSession {
return this.state.sessionId;
}
+ get features(): AgentFeature[] {
+ if (!hasOmpFastMode(this.state)) return [];
+ return [
+ {
+ type: "toggle",
+ id: "fast_mode",
+ label: "Fast",
+ icon: "zap",
+ value: this.state.fastModeEnabled === true,
+ ...(this.state.fastModeEnabled && !this.state.fastModeActive
+ ? { description: OMP_FAST_INACTIVE_MESSAGE, tooltip: OMP_FAST_INACTIVE_MESSAGE }
+ : {}),
+ },
+ ];
+ }
+
+ async setFeature(featureId: string, value: unknown): Promise {
+ if (featureId !== "fast_mode") throw new Error(`Unknown OMP feature: ${featureId}`);
+ if (typeof value !== "boolean") throw new Error("OMP fast mode requires a boolean");
+ if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
+ const result = await this.runtimeSession.setFastMode(value);
+ this.state = {
+ ...this.state,
+ fastModeEnabled: result.enabled,
+ fastModeActive: result.active,
+ };
+ this.config.featureValues = { ...this.config.featureValues, fast_mode: result.enabled };
+ }
+
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise {
return runProviderTurn({
prompt,
@@ -999,7 +846,7 @@ export class OmpAgentSession implements AgentSession {
const turnId = randomUUID();
this.live = true;
this.activeTurnId = turnId;
- this.activeClientMessageId = options?.clientMessageId ?? null;
+ this.rememberClientMessage(options?.clientMessageId ?? null, payload.text);
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
@@ -1011,6 +858,8 @@ export class OmpAgentSession implements AgentSession {
void (async () => {
try {
+ if (this.runtimeDead) await this.replaceRuntime(this.currentModeId ?? "full");
+ if (this.closed) throw new Error("OMP session is closed");
const ack = await this.runtimeSession.prompt(payload.text, payload.images);
this.activePromptRequestId = ack.requestId ?? null;
const correlatedResult = ack.requestId
@@ -1033,13 +882,7 @@ export class OmpAgentSession implements AgentSession {
return;
}
this.usagePoller.stopTurn();
- this.activeTurnId = null;
- this.activeClientMessageId = null;
- this.activeTurnStarted = false;
- this.activeTurnHasUserMessage = false;
- this.activeAssistantMessageId = null;
- this.activeTurnTerminalAssistantMessage = null;
- this.clearNoTurnBuffers();
+ this.resetActiveTurn({ terminalizeWork: true });
if (isOmpRequestAbortError(error)) {
this.emit({
type: "turn_canceled",
@@ -1061,6 +904,57 @@ export class OmpAgentSession implements AgentSession {
return { turnId };
}
+ async steerActiveTurn(
+ prompt: AgentPromptInput,
+ options: SteerActiveTurnOptions,
+ ): Promise {
+ if (this.closed || this.activeTurnId !== options.expectedTurnId)
+ return { status: "unavailable" };
+ const payload = convertPromptInput(prompt, { model: this.state.model });
+ if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" };
+ const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text);
+ try {
+ await this.runtimeSession.steer(payload.text, payload.images);
+ } catch (error) {
+ this.forgetClientMessage(submission);
+ if (isOmpSteerTransportFailure(error)) throw error;
+ return { status: "unavailable" };
+ }
+ if (this.closed || this.activeTurnId !== options.expectedTurnId)
+ return { status: "unavailable" };
+ if (options.clearPendingPermissions) {
+ for (const requestId of this.pendingExtensionUiRequests.keys()) {
+ await this.respondToPermission(requestId, {
+ behavior: "deny",
+ message: "The user sent a message instead of approving.",
+ });
+ }
+ }
+ return { status: "accepted" };
+ }
+
+ private rememberClientMessage(
+ clientMessageId: string | null,
+ text: string,
+ ): {
+ clientMessageId: string | null;
+ text: string;
+ } {
+ const submission = { clientMessageId, text };
+ this.pendingClientMessages.push(submission);
+ if (this.pendingClientMessages.length > 16) this.pendingClientMessages.shift();
+ return submission;
+ }
+
+ private forgetClientMessage(submission: { clientMessageId: string | null; text: string }): void {
+ const index = this.pendingClientMessages.indexOf(submission);
+ if (index >= 0) this.pendingClientMessages.splice(index, 1);
+ }
+
+ private takeClientMessage(): { clientMessageId: string | null; text: string } | undefined {
+ return this.pendingClientMessages.shift();
+ }
+
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
@@ -1073,6 +967,7 @@ export class OmpAgentSession implements AgentSession {
sessionFile: this.state.sessionFile,
runtimeSession: this.runtimeSession,
provider: this.provider,
+ bridgedTools: this.hostTools?.bridge?.toolIdentities(),
});
for (const item of mapOmpTodoState(this.state)) {
yield {
@@ -1085,6 +980,10 @@ export class OmpAgentSession implements AgentSession {
async getRuntimeInfo(): Promise {
await this.refreshState();
+ return this.runtimeInfoFromState();
+ }
+
+ private runtimeInfoFromState(): AgentRuntimeInfo {
return {
provider: this.provider,
sessionId: this.state.sessionId,
@@ -1109,10 +1008,56 @@ export class OmpAgentSession implements AgentSession {
if (!OMP_MODES.some((mode) => mode.id === modeId)) {
throw new Error(`Invalid OMP mode '${modeId}'`);
}
- return {
- type: "warning",
- message: "Start a new OMP session to change approval mode",
+ if (modeId === this.currentModeId) return;
+ if (this.activeTurnId || this.state.isStreaming || this.state.isCompacting) {
+ return { type: "warning", message: "Change approval mode once the current turn ends" };
+ }
+ await this.replaceRuntime(modeId);
+ }
+
+ private async replaceRuntime(modeId: string): Promise {
+ if (this.replacingRuntime) return this.replacingRuntime;
+ const replace = async () => {
+ const old = this.runtimeSession;
+ const oldHostTools = this.hostTools;
+ const previousSessionId = this.state.sessionId;
+ const restarted = await this.restartRuntime(
+ this.config.internal ? null : (this.state.sessionFile ?? null),
+ modeId,
+ );
+ const next = restarted.runtimeSession;
+ try {
+ if (this.closed) throw new Error("OMP session is closed");
+ const state = await next.getState();
+ if (this.closed) throw new Error("OMP session is closed");
+ this.unsubscribeRuntime?.();
+ this.runtimeSession = next;
+ this.hostTools = restarted.hostTools;
+ this.state = state;
+ this.currentModeId = modeId;
+ this.config.modeId = modeId;
+ this.runtimeDead = false;
+ this.attachRuntime(next);
+ this.subagentIndex.clear(old);
+ await oldHostTools?.close();
+ await old.close().catch(() => undefined);
+ if (state.sessionId !== previousSessionId) {
+ this.emit({
+ type: "thread_started",
+ provider: this.provider,
+ sessionId: state.sessionId,
+ });
+ }
+ } catch (error) {
+ await restarted.hostTools?.close();
+ await next.close().catch(() => undefined);
+ throw error;
+ }
};
+ this.replacingRuntime = replace().finally(() => {
+ this.replacingRuntime = null;
+ });
+ return this.replacingRuntime;
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -1126,15 +1071,12 @@ export class OmpAgentSession implements AgentSession {
}
this.pendingExtensionUiRequests.delete(requestId);
- if (isCombinedAskUserPermission(request)) {
- const combined = buildCombinedAskUserSelectionResponse(request, response);
- this.pendingCombinedAskUserResponse = combined.pendingResponse;
- this.runtimeSession.respondToExtensionUiRequest(requestId, combined.uiResponse);
+ const approvalResponse = buildOmpRpcUiPermissionResponse(request, response);
+ if (approvalResponse) {
+ this.runtimeSession.respondToExtensionUiRequest(requestId, approvalResponse);
} else {
- this.runtimeSession.respondToExtensionUiRequest(
- requestId,
- buildOmpRpcUiPermissionResponse(request, response) ??
- buildExtensionUiResponse(request, response),
+ this.questionUi.respond(request, response, (id, uiResponse) =>
+ this.runtimeSession.respondToExtensionUiRequest(id, uiResponse),
);
}
this.emit({
@@ -1153,6 +1095,9 @@ export class OmpAgentSession implements AgentSession {
nativeHandle: this.state.sessionFile,
metadata: {
cwd: this.config.cwd,
+ ...(this.hostTools?.bridge
+ ? { bridgedTools: [...this.hostTools.bridge.toolIdentities()] }
+ : {}),
...(this.config.model ? { model: this.config.model } : {}),
...(this.config.thinkingOptionId ? { thinkingOptionId: this.config.thinkingOptionId } : {}),
...(this.currentModeId ? { modeId: this.currentModeId } : {}),
@@ -1164,15 +1109,8 @@ export class OmpAgentSession implements AgentSession {
const turnId = this.activeTurnId;
await this.runtimeSession.abort();
if (turnId && this.activeTurnId === turnId) {
- this.terminalizeActiveWork();
this.usagePoller.stopTurn();
- this.activeTurnId = null;
- this.activeClientMessageId = null;
- this.activeTurnStarted = false;
- this.activeTurnHasUserMessage = false;
- this.activeAssistantMessageId = null;
- this.activeTurnTerminalAssistantMessage = null;
- this.clearNoTurnBuffers();
+ this.resetActiveTurn({ terminalizeWork: true });
this.emit({
type: "turn_canceled",
provider: this.provider,
@@ -1200,11 +1138,13 @@ export class OmpAgentSession implements AgentSession {
return;
}
this.closed = true;
+ this.unsubscribeRuntime?.();
this.usagePoller.close();
this.cancelNoTurnPromptCompletion();
try {
await this.runtimeSession.close();
} finally {
+ await this.hostTools?.close();
this.clearOmpSessionState();
}
}
@@ -1215,7 +1155,7 @@ export class OmpAgentSession implements AgentSession {
}
private clearOmpTurnState(): void {
- clearOmpHostToolState(this.runtimeSession);
+ this.hostTools?.clear();
this.subagentCardTracker.clear();
}
@@ -1230,6 +1170,17 @@ export class OmpAgentSession implements AgentSession {
this.clearOmpTurnState();
}
+ private resetActiveTurn({ terminalizeWork }: { terminalizeWork: boolean }): void {
+ if (terminalizeWork) this.terminalizeActiveWork();
+ this.activeTurnId = null;
+ this.activeAssistantMessageId = null;
+ this.activeTurnTerminalAssistantMessage = null;
+ this.activeTurnStarted = false;
+ this.activeTurnHasUserMessage = false;
+ this.pendingClientMessages.length = 0;
+ this.clearNoTurnBuffers();
+ }
+
async listCommands(): Promise {
if (this.commandCache) {
return this.commandCache;
@@ -1299,11 +1250,8 @@ export class OmpAgentSession implements AgentSession {
}
const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id);
- this.state = {
- ...this.state,
- model,
- };
this.config.model = `${model.provider}/${model.id}`;
+ await this.refreshState();
}
async setThinkingOption(thinkingOptionId: string | null): Promise {
@@ -1381,6 +1329,7 @@ export class OmpAgentSession implements AgentSession {
const outputs = this.pendingNoTurnOutputs.filter((output) => output.turnId === turnId);
this.clearNoTurnBuffers();
if (promptText) {
+ const clientMessageId = this.takeClientMessage()?.clientMessageId;
this.emit({
type: "timeline",
provider: this.provider,
@@ -1388,7 +1337,7 @@ export class OmpAgentSession implements AgentSession {
item: {
type: "user_message",
text: promptText,
- ...(this.activeClientMessageId ? { clientMessageId: this.activeClientMessageId } : {}),
+ ...(clientMessageId ? { clientMessageId } : {}),
},
});
}
@@ -1558,7 +1507,20 @@ export class OmpAgentSession implements AgentSession {
): void {
const message = optionalString(event.message);
if (event.method === "notify" && message) {
- this.bufferNoTurnOutput(message);
+ this.emit({
+ type: "timeline",
+ provider: this.provider,
+ turnId: this.currentTurnIdForEvent(),
+ item: {
+ type: "notification",
+ level:
+ event.notifyType === "warning" || event.notifyType === "error"
+ ? event.notifyType
+ : "info",
+ message,
+ },
+ });
+ return;
}
const sideEffectItem = this.mapExtensionUiSideEffect(event);
@@ -1572,22 +1534,11 @@ export class OmpAgentSession implements AgentSession {
return;
}
- if (this.respondToCombinedAskUserFollowUp(event)) {
- return;
- }
-
- const shouldCombineOptionalComment =
- event.method === "select" &&
- this.activeAskUserDialog?.allowComment === true &&
- this.activeAskUserDialog.allowMultiple === false;
const request =
mapOmpRpcUiPermissionRequest(event, { provider: this.provider }) ??
- mapExtensionUiRequestToPermission(event, {
- provider: this.provider,
- label: "OMP",
- combineOptionalComment: shouldCombineOptionalComment,
- allowFreeform: this.activeAskUserDialog?.allowFreeform,
- });
+ this.questionUi.handleRequest(event, this.provider, (id, response) =>
+ this.runtimeSession.respondToExtensionUiRequest(id, response),
+ );
if (!request) {
return;
}
@@ -1617,33 +1568,6 @@ export class OmpAgentSession implements AgentSession {
return { type: "assistant_message", text: lines.join("\n") };
}
- private respondToCombinedAskUserFollowUp(
- event: Extract,
- ): boolean {
- const pending = this.pendingCombinedAskUserResponse;
- if (!pending || event.method !== "input") {
- return false;
- }
-
- const placeholder = optionalString(event.placeholder);
- if (pending.freeform !== null && !isOptionalInputPlaceholder(placeholder)) {
- this.pendingCombinedAskUserResponse = {
- ...pending,
- freeform: null,
- };
- this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.freeform });
- return true;
- }
-
- if (isOptionalInputPlaceholder(placeholder)) {
- this.pendingCombinedAskUserResponse = null;
- this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.comment });
- return true;
- }
-
- return false;
- }
-
private handleCommandOutput(textValue: unknown): void {
if (!this.activeTurnId) {
return;
@@ -1665,13 +1589,21 @@ export class OmpAgentSession implements AgentSession {
}
private handleExtraRuntimeEvent(event: OmpRuntimeEvent): boolean {
- if (
- handleOmpHostToolRuntimeEvent(event, {
- runtimeSession: this.runtimeSession,
- paseoTools: this.paseoTools,
- logger: this.logger,
- })
- ) {
+ if (event.type === "model_changed") {
+ void this.refreshState()
+ .then(() =>
+ this.emit({
+ type: "model_changed",
+ provider: this.provider,
+ runtimeInfo: this.runtimeInfoFromState(),
+ }),
+ )
+ .catch((error: unknown) =>
+ this.logger.debug({ err: error }, "OMP state unavailable after model change"),
+ );
+ return true;
+ }
+ if (handleOmpHostToolRuntimeEvent(event, this.hostTools, this.runtimeSession, this.logger)) {
return true;
}
if (event.type === "subagent_lifecycle") {
@@ -1818,19 +1750,18 @@ export class OmpAgentSession implements AgentSession {
}
private handleProcessExit(error: string): void {
+ this.runtimeDead = true;
+ void this.hostTools?.close();
this.usagePoller.stopTurn();
- this.terminalizeActiveWork();
- this.subagentIndex.clear(this.runtimeSession);
if (!this.activeTurnId) {
+ this.terminalizeActiveWork();
+ this.subagentIndex.clear(this.runtimeSession);
+ this.emit({ type: "turn_failed", provider: this.provider, error });
return;
}
const turnId = this.activeTurnId;
- this.activeTurnId = null;
- this.activeClientMessageId = null;
- this.activeTurnStarted = false;
- this.activeTurnHasUserMessage = false;
- this.activeTurnTerminalAssistantMessage = null;
- this.clearNoTurnBuffers();
+ this.resetActiveTurn({ terminalizeWork: true });
+ this.subagentIndex.clear(this.runtimeSession);
this.emit({
type: "turn_failed",
provider: this.provider,
@@ -1876,7 +1807,7 @@ export class OmpAgentSession implements AgentSession {
case "tool_execution_start": {
const toolCall = parseToolArgs(event.toolName, event.args);
this.activeToolCalls.set(event.toolCallId, toolCall);
- this.activeAskUserDialog = readActiveAskUserDialog(event.toolName, event.args);
+ this.questionUi.start(event.toolName, event.args);
this.emitToolCallEvent(event.toolCallId, toolCall, "running", null, null);
return;
}
@@ -1946,14 +1877,12 @@ export class OmpAgentSession implements AgentSession {
this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null);
this.activeToolCalls.delete(event.toolCallId);
- if (event.toolName === "ask_user") {
- this.activeAskUserDialog = null;
- this.pendingCombinedAskUserResponse = null;
- }
+ this.questionUi.finish(event.toolName);
const result = parseToolResult(event.result);
- const error = event.isError ? event.result : null;
- const status = event.isError ? "failed" : "completed";
+ const failed = isOmpToolFailure(toolCall, result, Boolean(event.isError));
+ const error = failed ? toolFailureMessage(result) : null;
+ const status = failed ? "failed" : "completed";
this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error);
if (event.toolName === "task") {
this.subagentCardTracker.delete(event.toolCallId);
@@ -2048,6 +1977,7 @@ export class OmpAgentSession implements AgentSession {
): void {
if (event.message.role === "assistant") {
this.activeAssistantMessageId = null;
+ this.questionUi.observeMessage(event.message);
if (turnId) {
this.activeTurnTerminalAssistantMessage = event.message;
}
@@ -2057,6 +1987,11 @@ export class OmpAgentSession implements AgentSession {
if (shouldDisplayOmpCustomMessage(event.message)) {
const text = getUserMessageText(event.message.content);
if (text) {
+ const skillPrompt = ompSkillPromptUserText(event.message);
+ if (skillPrompt) {
+ this.emitSkillPromptEcho(turnId);
+ return;
+ }
const item =
mapOmpAdvisorMessageToToolCall(event.message, text) ??
mapOmpSystemNoticeToNotification(text);
@@ -2064,7 +1999,14 @@ export class OmpAgentSession implements AgentSession {
type: "timeline",
provider: this.provider,
turnId,
- item: item ?? { type: "assistant_message", text },
+ item: item ?? {
+ type: "assistant_message",
+ text,
+ messageId: ompCustomMessageId(event.message, () => {
+ this.customMessageIndex += 1;
+ return this.customMessageIndex;
+ }),
+ },
});
}
}
@@ -2080,7 +2022,6 @@ export class OmpAgentSession implements AgentSession {
}
const nativeMessage = event.message as OmpAgentMessage & { id?: unknown; entryId?: unknown };
const messageId = readNativeMessageId(nativeMessage);
- const clientMessageId = this.activeClientMessageId;
const emitUserMessage = (resolvedMessageId?: string): void => {
if (resolvedMessageId) {
// OMP re-emits user message_end frames for entries it has already
@@ -2091,6 +2032,7 @@ export class OmpAgentSession implements AgentSession {
}
this.emittedUserMessageIds.add(resolvedMessageId);
}
+ const clientMessageId = this.takeClientMessage()?.clientMessageId;
this.emit({
type: "timeline",
provider: this.provider,
@@ -2121,6 +2063,22 @@ export class OmpAgentSession implements AgentSession {
});
}
+ private emitSkillPromptEcho(turnId: string | undefined): void {
+ const pending = this.takeClientMessage();
+ if (!pending) return;
+ this.activeTurnHasUserMessage = true;
+ this.emit({
+ type: "timeline",
+ provider: this.provider,
+ turnId,
+ item: {
+ type: "user_message",
+ text: pending.text,
+ ...(pending.clientMessageId ? { clientMessageId: pending.clientMessageId } : {}),
+ },
+ });
+ }
+
private emitToolCallEvent(
toolCallId: string,
toolCall: OmpTrackedToolCall,
@@ -2133,10 +2091,13 @@ export class OmpAgentSession implements AgentSession {
if (!detail) {
return false;
}
+ const bridgedTool = this.hostTools?.bridge?.tool(toolCall.toolName);
const baseItem = {
type: "tool_call" as const,
callId: toolCallId,
- name: resolveToolCallName(toolCall, result),
+ name: bridgedTool
+ ? `${bridgedTool.server} / ${bridgedTool.tool}`
+ : resolveToolCallName(toolCall, result),
detail,
};
const item =
@@ -2157,24 +2118,18 @@ export class OmpAgentSession implements AgentSession {
): ToolCallDetail | null {
return mapOmpToolDetail(toolCall, result, {
toolCallId,
+ bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName),
mapSubagentDetail: (detail) =>
this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail,
});
}
private completeTurn(turnId: string | undefined, messages: OmpAgentMessage[]): void {
- this.activeTurnId = null;
- this.activeClientMessageId = null;
- this.activeAssistantMessageId = null;
- this.activeTurnTerminalAssistantMessage = null;
- this.activeTurnStarted = false;
- this.activeTurnHasUserMessage = false;
- this.clearNoTurnBuffers();
// OMP reports a stopped turn as a terminal response carrying its interrupt
// text as an error. That is the user's own Stop, not a failed turn.
if (isOmpAbortedTerminalResponse(messages)) {
this.usagePoller.stopTurn();
- this.terminalizeActiveWork();
+ this.resetActiveTurn({ terminalizeWork: true });
this.emit({
type: "turn_canceled",
provider: this.provider,
@@ -2186,6 +2141,7 @@ export class OmpAgentSession implements AgentSession {
const errorMessage = latestOmpErrorMessage(messages);
if (typeof errorMessage === "string" && errorMessage.length > 0) {
this.usagePoller.stopTurn();
+ this.resetActiveTurn({ terminalizeWork: true });
this.emit({
type: "turn_failed",
provider: this.provider,
@@ -2195,6 +2151,7 @@ export class OmpAgentSession implements AgentSession {
return;
}
const finalUsage = this.usagePoller.completeTurn(turnId);
+ this.resetActiveTurn({ terminalizeWork: false });
this.emit({
type: "turn_completed",
provider: this.provider,
@@ -2207,7 +2164,19 @@ export class OmpAgentSession implements AgentSession {
turnId: string | undefined,
messages: OmpAgentMessage[],
): Promise {
+ const deadline = Date.now() + this.providerIdleDeadlineMs;
while (!this.closed && this.activeTurnStarted && this.currentTurnIdForEvent() === turnId) {
+ if (Date.now() >= deadline) {
+ this.usagePoller.stopTurn();
+ this.resetActiveTurn({ terminalizeWork: true });
+ this.emit({
+ type: "turn_failed",
+ provider: this.provider,
+ turnId,
+ error: "OMP provider idle deadline exceeded",
+ });
+ return;
+ }
try {
const state = await this.runtimeSession.getState();
this.state = state;
@@ -2247,6 +2216,7 @@ export class OmpAgentClient implements AgentClient {
private readonly providerIdleScheduler?: OmpProviderIdleScheduler;
private readonly noTurnScheduler?: OmpNoTurnScheduler;
private readonly usagePollScheduler?: OmpUsagePollScheduler;
+ private readonly providerIdleDeadlineMs?: number;
private readonly runtime: OmpRuntime;
constructor(options: OmpAgentClientOptions) {
@@ -2270,6 +2240,7 @@ export class OmpAgentClient implements AgentClient {
this.providerIdleScheduler = options.providerIdleScheduler;
this.noTurnScheduler = options.noTurnScheduler;
this.usagePollScheduler = options.usagePollScheduler;
+ this.providerIdleDeadlineMs = options.providerIdleDeadlineMs;
this.runtime =
options.runtime ?? createRuntime(options.logger, runtimeSettings, this.providerParams);
}
@@ -2277,11 +2248,37 @@ export class OmpAgentClient implements AgentClient {
private async configureNativePaseoTools(
runtimeSession: OmpRuntimeSession,
catalog: PaseoToolCatalog | undefined,
- ): Promise {
- if (!catalog) {
+ config: AgentSessionConfig,
+ launchEnv?: NodeJS.ProcessEnv,
+ ): Promise {
+ if (!catalog && !config.mcpServers) {
return;
}
- await setOmpHostTools(runtimeSession, catalog);
+ const bridge = await OmpMcpBridge.connect(config.mcpServers, config.cwd, this.logger, {
+ ...process.env,
+ ...this.runtimeSettings?.env,
+ ...launchEnv,
+ });
+ const router = new OmpHostToolRouter({ runtimeSession, catalog, bridge, logger: this.logger });
+ try {
+ await router.register();
+ return router;
+ } catch (error) {
+ await router.close();
+ throw error;
+ }
+ }
+
+ private async restoreFastMode(
+ runtimeSession: OmpRuntimeSession,
+ config: AgentSessionConfig,
+ state: OmpSessionState,
+ ): Promise {
+ const value = config.featureValues?.fast_mode;
+ if (typeof value !== "boolean" || !hasOmpFastMode(state) || value === state.fastModeEnabled)
+ return state;
+ const result = await runtimeSession.setFastMode(value);
+ return { ...state, fastModeEnabled: result.enabled, fastModeActive: result.active };
}
async createSession(
@@ -2289,7 +2286,7 @@ export class OmpAgentClient implements AgentClient {
launchContext?: AgentLaunchContext,
): Promise {
const launchMode = this.resolveLaunchMode(config.modeId);
- const runtimeSession = await this.runtime.startSession({
+ const startInput: OmpStartSessionInput = {
cwd: config.cwd,
protocolMode: "rpc-ui",
model: config.model,
@@ -2299,23 +2296,38 @@ export class OmpAgentClient implements AgentClient {
extraArgs: launchMode.extraArgs,
systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt),
env: launchContext?.env,
- });
+ };
+ const runtimeSession = await this.runtime.startSession(startInput);
+ let hostTools: OmpHostToolRouter | undefined;
try {
- await this.configureNativePaseoTools(runtimeSession, launchContext?.paseoTools);
- return new OmpAgentSession({
+ hostTools = await this.configureNativePaseoTools(
+ runtimeSession,
+ launchContext?.paseoTools,
+ config,
+ startInput.env,
+ );
+ const initialState = await this.restoreFastMode(
runtimeSession,
config,
- initialState: await runtimeSession.getState(),
+ await runtimeSession.getState(),
+ );
+ return new OmpAgentSession({
+ runtimeSession,
+ hostTools,
+ restartRuntime: this.buildRestartRuntime(startInput, config, launchContext),
+ config,
+ initialState,
currentModeId: launchMode.modeId,
logger: this.logger,
subagentCardScheduler: this.subagentCardScheduler,
providerIdleScheduler: this.providerIdleScheduler,
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
- paseoTools: launchContext?.paseoTools,
+ providerIdleDeadlineMs: this.providerIdleDeadlineMs,
usageEnv: { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env },
});
} catch (error) {
+ await hostTools?.close();
await runtimeSession.close().catch(() => undefined);
throw error;
}
@@ -2325,6 +2337,7 @@ export class OmpAgentClient implements AgentClient {
handle: AgentPersistenceHandle,
overrides?: Partial,
launchContext?: AgentLaunchContext,
+ options?: AgentResumeSessionOptions,
): Promise {
const sessionFile = handle.nativeHandle;
if (!sessionFile) {
@@ -2334,37 +2347,94 @@ export class OmpAgentClient implements AgentClient {
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider);
- const launchMode = this.resolveLaunchMode(resumeConfig.modeId);
- const runtimeSession = await this.runtime.startSession(
- buildResumeStartInput({
+ if (options?.purpose === "history") {
+ return new OmpHistorySession(
+ handle,
resumeConfig,
sessionFile,
- launchContext,
- launchMode,
- }),
- );
+ this.provider,
+ persistenceMetadata.bridgedTools,
+ );
+ }
+
+ const launchMode = this.resolveLaunchMode(resumeConfig.modeId);
+ const startInput = buildResumeStartInput({
+ resumeConfig,
+ sessionFile,
+ launchContext,
+ launchMode,
+ });
+ const runtimeSession = await this.runtime.startSession(startInput);
+ let hostTools: OmpHostToolRouter | undefined;
try {
- await this.configureNativePaseoTools(runtimeSession, launchContext?.paseoTools);
+ hostTools = await this.configureNativePaseoTools(
+ runtimeSession,
+ launchContext?.paseoTools,
+ resumeConfig.config,
+ startInput.env,
+ );
+ const initialState = await this.restoreFastMode(
+ runtimeSession,
+ resumeConfig.config,
+ await runtimeSession.getState(),
+ );
return new OmpAgentSession({
runtimeSession,
+ hostTools,
+ restartRuntime: this.buildRestartRuntime(startInput, resumeConfig.config, launchContext),
config: resumeConfig.config,
- initialState: await runtimeSession.getState(),
+ initialState,
currentModeId: launchMode.modeId,
logger: this.logger,
subagentCardScheduler: this.subagentCardScheduler,
providerIdleScheduler: this.providerIdleScheduler,
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
- paseoTools: launchContext?.paseoTools,
+ providerIdleDeadlineMs: this.providerIdleDeadlineMs,
usageEnv: { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env },
live: false,
});
} catch (error) {
+ await hostTools?.close();
await runtimeSession.close().catch(() => undefined);
throw error;
}
}
+ private buildRestartRuntime(
+ startInput: OmpStartSessionInput,
+ config: AgentSessionConfig,
+ launchContext?: AgentLaunchContext,
+ ): OmpAgentSessionOptions["restartRuntime"] {
+ return async (sessionFile, modeId) => {
+ const launchMode = this.resolveLaunchMode(modeId);
+ const next = await this.runtime.startSession({
+ ...startInput,
+ model: config.model,
+ thinkingOptionId: normalizeOmpThinkingOption(config.thinkingOptionId) ?? undefined,
+ modeId: launchMode.modeId,
+ extraArgs: launchMode.extraArgs,
+ ...(!startInput.noSession && sessionFile ? { session: sessionFile } : {}),
+ });
+ let hostTools: OmpHostToolRouter | undefined;
+ try {
+ hostTools = await this.configureNativePaseoTools(
+ next,
+ launchContext?.paseoTools,
+ config,
+ startInput.env,
+ );
+ const state = await next.getState();
+ await this.restoreFastMode(next, config, state);
+ return { runtimeSession: next, hostTools };
+ } catch (error) {
+ await hostTools?.close();
+ await next.close().catch(() => undefined);
+ throw error;
+ }
+ };
+ }
+
async fetchCatalog(
options: FetchCatalogOptions,
context?: ProviderRefreshContext,
@@ -2421,7 +2491,14 @@ export class OmpAgentClient implements AgentClient {
}
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
- const importConfig = await readOmpImportSessionConfig(input.providerHandleId);
+ const descriptorOptions = {
+ sessionDir: this.providerParams.sessionDir,
+ runtimeSettings: this.runtimeSettings,
+ };
+ const importConfig = await readOmpImportSessionConfig(
+ input.providerHandleId,
+ descriptorOptions,
+ );
return importSessionFromPersistence({
provider: this.provider,
request: input,
diff --git a/packages/server/src/server/agent/providers/omp/ask.test.ts b/packages/server/src/server/agent/providers/omp/ask.test.ts
new file mode 100644
index 0000000000..7c72004e4a
--- /dev/null
+++ b/packages/server/src/server/agent/providers/omp/ask.test.ts
@@ -0,0 +1,311 @@
+import { describe, expect, test } from "vitest";
+
+import { OmpHarness } from "./test-utils/omp-harness.js";
+
+function startAsk(omp: OmpHarness, multi: boolean): void {
+ omp.emit({
+ type: "tool_execution_start",
+ toolCallId: "ask-1",
+ toolName: "ask",
+ args: {
+ questions: [
+ {
+ id: "colors",
+ question: "Which colors?",
+ multi,
+ options: [
+ { label: "Red", description: "Warm" },
+ { label: "Blue", description: "Cool" },
+ ],
+ },
+ ],
+ },
+ });
+}
+
+function select(omp: OmpHarness, id: string, title: string, options: string[]): void {
+ omp.emit({ type: "extension_ui_request", id, method: "select", title, options });
+}
+
+describe("OMP ask RPC UI", () => {
+ test("recognizes ask arguments from the assistant message before the UI request", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ omp.emit({
+ type: "message_end",
+ message: {
+ role: "assistant",
+ content: [
+ {
+ type: "toolCall",
+ id: "ask-1",
+ name: "ask",
+ arguments: {
+ questions: [
+ {
+ id: "colors",
+ question: "Which colors?",
+ multi: true,
+ options: [
+ { label: "Red", description: "Warm" },
+ { label: "Blue", description: "Cool" },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ },
+ });
+ select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
+ expect(omp.pendingPermissions()[0]?.input?.questions?.[0]).toMatchObject({
+ multiSelect: true,
+ allowOther: true,
+ });
+ startAsk(omp, true);
+ });
+ test("answers the multi-select loop from one Paseo question", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ startAsk(omp, true);
+ select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
+
+ expect(omp.pendingPermissions()).toHaveLength(1);
+ expect(omp.pendingPermissions()[0]).toMatchObject({
+ kind: "question",
+ input: {
+ questions: [
+ {
+ question: "Which colors?",
+ multiSelect: true,
+ allowOther: true,
+ options: [{ label: "Red" }, { label: "Blue" }],
+ },
+ ],
+ },
+ });
+ await omp.respondToPermission("select-1", {
+ behavior: "allow",
+ updatedInput: { answers: { Response: "Blue, Red" } },
+ });
+ select(omp, "select-2", "(1 selected) Which colors?", [
+ "Red",
+ "Blue",
+ "✓ Done selecting",
+ "Other (type your own)",
+ ]);
+ select(omp, "select-3", "(2 selected) Which colors?", [
+ "Red",
+ "Blue",
+ "✔ Done selecting",
+ "Other (type your own)",
+ ]);
+
+ expect(omp.pendingPermissions()).toHaveLength(0);
+ expect(omp.extensionUiResponses()).toEqual([
+ { id: "select-1", response: { value: "Blue" } },
+ { id: "select-2", response: { value: "Red" } },
+ { id: "select-3", response: { value: "✔ Done selecting" } },
+ ]);
+ });
+
+ test("passes a single-select Other answer through OMP's editor", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ startAsk(omp, false);
+ select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
+ expect(omp.pendingPermissions()[0]?.input?.questions?.[0]).toMatchObject({ allowOther: true });
+ await omp.respondToPermission("select-1", {
+ behavior: "allow",
+ updatedInput: { answers: { Response: "Violet" } },
+ });
+ omp.emit({
+ type: "extension_ui_request",
+ id: "editor-1",
+ method: "editor",
+ title: "Which colors? ○ Red ○ Blue ◉ Other (type your own) Enter your response:",
+ });
+
+ expect(omp.pendingPermissions()).toHaveLength(0);
+ expect(omp.extensionUiResponses()).toEqual([
+ { id: "select-1", response: { value: "Other (type your own)" } },
+ { id: "editor-1", response: { value: "Violet" } },
+ ]);
+ });
+
+ test("passes multi-select choices and Other through the same editor flow", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ startAsk(omp, true);
+ select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
+ await omp.respondToPermission("select-1", {
+ behavior: "allow",
+ updatedInput: { answers: { Response: "Red, Violet" } },
+ });
+ select(omp, "select-2", "(1 selected) Which colors?", [
+ "Red",
+ "Blue",
+ "✓ Done selecting",
+ "Other (type your own)",
+ ]);
+ omp.emit({
+ type: "extension_ui_request",
+ id: "editor-1",
+ method: "editor",
+ title: "(1 selected) Which colors?",
+ });
+
+ expect(omp.pendingPermissions()).toHaveLength(0);
+ expect(omp.extensionUiResponses()).toEqual([
+ { id: "select-1", response: { value: "Red" } },
+ { id: "select-2", response: { value: "Other (type your own)" } },
+ { id: "editor-1", response: { value: "Violet" } },
+ ]);
+ });
+
+ test("cancels an ask without leaving replay state behind", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ startAsk(omp, true);
+ select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
+ await omp.respondToPermission("select-1", { behavior: "deny" });
+ select(omp, "select-after-cancel", "Another question", ["One", "Two"]);
+
+ expect(omp.extensionUiResponses()).toEqual([{ id: "select-1", response: { cancelled: true } }]);
+ expect(omp.pendingPermissions()).toHaveLength(1);
+ expect(omp.pendingPermissions()[0]?.id).toBe("select-after-cancel");
+ });
+
+ test("keeps single choices, tool approvals, and free-form input independent", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ startAsk(omp, false);
+ select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
+ await omp.respondToPermission("select-1", {
+ behavior: "allow",
+ updatedInput: { answers: { Response: "Blue" } },
+ });
+ expect(omp.extensionUiResponses()).toContainEqual({
+ id: "select-1",
+ response: { value: "Blue" },
+ });
+
+ omp.emit({
+ type: "tool_execution_end",
+ toolCallId: "ask-1",
+ toolName: "ask",
+ result: "Blue",
+ isError: false,
+ });
+ omp.requestToolApproval({ id: "approval", tool: "bash", detail: "echo hi" });
+ expect(omp.pendingPermissions()[0]).toMatchObject({ id: "approval", kind: "tool" });
+ await omp.respondToPermission("approval", { behavior: "allow" });
+ expect(omp.extensionUiResponses()).toContainEqual({
+ id: "approval",
+ response: { value: "Approve" },
+ });
+
+ omp.emit({ type: "extension_ui_request", id: "input", method: "input", title: "Type a value" });
+ expect(omp.pendingPermissions()[0]).toMatchObject({ id: "input", kind: "question" });
+ await omp.respondToPermission("input", {
+ behavior: "allow",
+ updatedInput: { answers: { Response: "typed value" } },
+ });
+ expect(omp.extensionUiResponses()).toContainEqual({
+ id: "input",
+ response: { value: "typed value" },
+ });
+ });
+
+ test("recognizes OMP's numbered question title", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ startAsk(omp, true);
+ select(omp, "select-1", "Which colors? (1/2)", ["Red", "Blue", "Other (type your own)"]);
+ expect(omp.pendingPermissions()[0]?.input?.questions?.[0]).toMatchObject({ multiSelect: true });
+ });
+
+ test("keeps descriptions with their labels when a select event reorders rows", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ startAsk(omp, true);
+ select(omp, "select-1", "Which colors?", ["Blue", "Red", "Other (type your own)"]);
+
+ expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toEqual([
+ { label: "Blue", description: "Cool" },
+ { label: "Red", description: "Warm" },
+ ]);
+ });
+
+ test("keeps a recommended option's description with its display label", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ startAsk(omp, false);
+ select(omp, "select-1", "Which colors?", [
+ "Red (Recommended)",
+ "Blue",
+ "Other (type your own)",
+ ]);
+ expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toEqual([
+ { label: "Red (Recommended)", description: "Warm" },
+ { label: "Blue", description: "Cool" },
+ ]);
+ });
+
+ test("cancels cleanly when OMP omits Done selecting in a multi-question ask", async () => {
+ const omp = new OmpHarness();
+ await omp.start();
+ omp.emit({
+ type: "tool_execution_start",
+ toolCallId: "ask-1",
+ toolName: "ask",
+ args: {
+ questions: [
+ {
+ id: "colors",
+ question: "Which colors?",
+ multi: true,
+ options: [{ label: "Red" }, { label: "Blue" }],
+ },
+ {
+ id: "shape",
+ question: "Which shape?",
+ options: [{ label: "Round" }, { label: "Square" }],
+ },
+ ],
+ },
+ });
+ select(omp, "select-1", "Which colors? (1/2)", ["Red", "Blue", "Other (type your own)"]);
+ await omp.respondToPermission("select-1", {
+ behavior: "allow",
+ updatedInput: { answers: { Response: "Red" } },
+ });
+ // OMP's RPC select omits the right-arrow navigation callback and, with
+ // allowForward true, also omits its Done row on every follow-up request.
+ select(omp, "select-2", "(1 selected) Which colors? (1/2)", [
+ "Red",
+ "Blue",
+ "Other (type your own)",
+ ]);
+
+ expect(omp.extensionUiResponses()).toEqual([
+ { id: "select-1", response: { value: "Red" } },
+ { id: "select-2", response: { cancelled: true } },
+ ]);
+ expect(omp.pendingPermissions()).toHaveLength(0);
+ omp.emit({
+ type: "tool_execution_end",
+ toolCallId: "ask-1",
+ toolName: "ask",
+ result: "Ask tool was cancelled by the user",
+ isError: true,
+ });
+ expect(omp.timeline()).toContainEqual(
+ expect.objectContaining({
+ type: "tool_call",
+ name: "ask",
+ status: "failed",
+ }),
+ );
+ });
+});
diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts
index cb8bd13b51..f312079491 100644
--- a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts
+++ b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts
@@ -98,6 +98,14 @@ function replyToCommands(
});
}
+function capturePendingCommand(child: OmpChild, type: string): Promise> {
+ return new Promise((resolve) => {
+ onOmpCommand(child, (command) => {
+ if (command.type === type) resolve(command);
+ });
+ });
+}
+
/** Kill the child the moment it receives `type`, so the request is in flight when it dies. */
function exitOnCommand(child: OmpChild, type: string): void {
onOmpCommand(child, (command) => {
@@ -113,6 +121,22 @@ function withoutRequestId(command: Record): Record {
+ test("steer waits for OMP's response and surfaces rejection", async () => {
+ const child = createOmpChild();
+ let pending: Record | null = null;
+ onOmpCommand(child, (command) => {
+ if (command.type === "steer") pending = command;
+ });
+ const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
+ const result = session.steer("change direction");
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(pending).toMatchObject({ type: "steer", message: "change direction" });
+ child.stdout.write(
+ `${JSON.stringify({ id: pending!.id, type: "response", command: "steer", success: false, error: "rejected" })}\n`,
+ );
+ await expect(result).rejects.toThrow("rejected");
+ await session.close();
+ });
test("uses the configured RPC timeout and attributes the pending phase", async () => {
vi.useFakeTimers();
const child = createOmpChild();
@@ -293,6 +317,27 @@ describe("OMP CLI runtime", () => {
]);
});
+ test("compact waits beyond the control-plane timeout for a late response", async () => {
+ vi.useFakeTimers();
+ const child = createOmpChild();
+ const pending = capturePendingCommand(child, "compact");
+ const session = await createRuntime(child, [], { requestTimeoutMs: 100 }).startSession({
+ cwd: "/workspace/project",
+ });
+ try {
+ const compact = session.compact("focus on tests");
+ const command = await pending;
+ await vi.advanceTimersByTimeAsync(101);
+ child.stdout.write(
+ `${JSON.stringify({ id: command.id, type: "response", command: "compact", success: true, data: {} })}\n`,
+ );
+ await expect(compact).resolves.toBeUndefined();
+ } finally {
+ vi.useRealTimers();
+ await session.close();
+ }
+ });
+
test("accepts the empty prompt acknowledgement emitted by OMP 17", async () => {
const child = createOmpChild();
replyToCommands(child, () => undefined);
diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.ts
index 06e5158a03..61ca592993 100644
--- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts
+++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts
@@ -1,8 +1,13 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
+import { z } from "zod";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
-import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js";
+import {
+ JSONL_RPC_NO_TIMEOUT,
+ JsonlRpcProcess,
+ type JsonlRpcLaunch,
+} from "../jsonl-rpc-process.js";
import { establishOmpProtocol } from "./protocol-session.js";
import {
buildOmpLaunch,
@@ -144,10 +149,13 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
}
async compact(customInstructions?: string): Promise {
- await this.request({
- type: "compact",
- ...(customInstructions ? { customInstructions } : {}),
- });
+ await this.request(
+ {
+ type: "compact",
+ ...(customInstructions ? { customInstructions } : {}),
+ },
+ JSONL_RPC_NO_TIMEOUT,
+ );
}
async setAutoCompaction(enabled: boolean): Promise {
@@ -162,6 +170,11 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
return OmpSessionStateSchema.parse(await this.request({ type: "get_state" }));
}
+ async setFastMode(enabled: boolean): Promise<{ enabled: boolean; active: boolean }> {
+ const result = await this.request({ type: "set_fast_mode", enabled });
+ return z.object({ enabled: z.boolean(), active: z.boolean() }).parse(result);
+ }
+
async getMessages(): Promise {
const data = OmpMessagesResultSchema.parse(await this.request({ type: "get_messages" }));
return data.messages ?? [];
@@ -255,8 +268,11 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
return data.messages ?? [];
}
- steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void {
- this.process.send({ type: "steer", message, ...(images?.length ? { images } : {}) });
+ async steer(
+ message: string,
+ images?: Array<{ type: "image"; data: string; mimeType: string }>,
+ ): Promise {
+ await this.request({ type: "steer", message, ...(images?.length ? { images } : {}) });
}
followUp(
diff --git a/packages/server/src/server/agent/providers/omp/custom-message.ts b/packages/server/src/server/agent/providers/omp/custom-message.ts
index e84095b4f0..4a85b198e7 100644
--- a/packages/server/src/server/agent/providers/omp/custom-message.ts
+++ b/packages/server/src/server/agent/providers/omp/custom-message.ts
@@ -5,3 +5,30 @@ type OmpCustomMessage = Extract;
export function shouldDisplayOmpCustomMessage(message: OmpCustomMessage): boolean {
return Reflect.get(message, "display") !== false;
}
+
+// injected rows need their own message id so the stream coalescer never glues them
+// onto the assistant reply they arrive next to
+export function ompCustomMessageId(message: OmpCustomMessage, nextIndex: () => number): string {
+ const id = Reflect.get(message, "id");
+ return `omp-custom-${typeof id === "string" && id ? id : nextIndex()}`;
+}
+
+// omp does not persist the typed /skill: as a user message, only the expansion
+// it injected on the user's behalf; rebuild the bubble from the expansion's details
+export function ompSkillPromptUserText(message: OmpCustomMessage): string | null {
+ if (
+ Reflect.get(message, "customType") !== "skill-prompt" ||
+ Reflect.get(message, "attribution") !== "user"
+ ) {
+ return null;
+ }
+ const details = Reflect.get(message, "details");
+ const name = details && typeof details === "object" ? Reflect.get(details, "name") : undefined;
+ if (typeof name !== "string" || !name) {
+ return null;
+ }
+ const args = Reflect.get(details as object, "args");
+ return typeof args === "string" && args.trim()
+ ? `/skill:${name} ${args.trim()}`
+ : `/skill:${name}`;
+}
diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts
index 47cc41c0d0..9a6d9a5b82 100644
--- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts
+++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts
@@ -8,7 +8,7 @@ import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./messag
import type { OmpAgentMessage } from "./rpc-types.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
-import { streamOmpHistory } from "./history.js";
+import { readOmpHistoryTodoState, streamOmpHistory } from "./history.js";
async function collectHistory(
messages: OmpAgentMessage[],
@@ -27,6 +27,117 @@ async function collectHistory(
}
describe("OMP history mapper", () => {
+ test("replays a web search details error as failed when OMP sets isError false", async () => {
+ const events = await collectHistory([
+ {
+ role: "assistant",
+ content: [
+ { type: "toolCall", id: "web-1", name: "web_search", arguments: { query: "Paseo" } },
+ ],
+ },
+ {
+ role: "toolResult",
+ toolCallId: "web-1",
+ toolName: "web_search",
+ content: [{ type: "text", text: "Error: All web search providers failed" }],
+ details: {
+ error: "All web search providers failed",
+ response: { provider: "none", sources: [] },
+ },
+ isError: false,
+ },
+ ]);
+ expect(events.at(-1)?.item).toMatchObject({
+ type: "tool_call",
+ status: "failed",
+ error: "All web search providers failed",
+ });
+ });
+
+ test("restores blocked and abandoned todo state from a session file", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "omp-todo-state-history-"));
+ const sessionFile = join(dir, "session.jsonl");
+ writeFileSync(
+ sessionFile,
+ [
+ { type: "session", id: "root" },
+ {
+ type: "message",
+ id: "todos",
+ parentId: "root",
+ message: {
+ role: "toolResult",
+ toolName: "todo",
+ details: {
+ phases: [
+ {
+ name: "Tasks",
+ tasks: [
+ { content: "Wait for approval", status: "blocked", blocker: "review" },
+ { content: "Old route", status: "abandoned" },
+ ],
+ },
+ ],
+ },
+ },
+ },
+ ]
+ .map((entry) => JSON.stringify(entry))
+ .join("\n"),
+ );
+ expect(await readOmpHistoryTodoState(sessionFile)).toEqual({
+ type: "todo",
+ items: [{ text: "Wait for approval (blocked: review)", status: "pending", completed: false }],
+ });
+ });
+
+ test("hides persisted developer reminders and shows other developer messages", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "omp-developer-history-"));
+ const sessionFile = join(dir, "session.jsonl");
+ writeFileSync(
+ sessionFile,
+ [
+ { type: "session", id: "root" },
+ {
+ type: "message",
+ id: "reminder",
+ parentId: "root",
+ message: {
+ role: "developer",
+ attribution: "agent",
+ content: [
+ {
+ type: "text",
+ text: "\nContinue unfinished tasks\n ",
+ },
+ ],
+ },
+ },
+ {
+ type: "message",
+ id: "other",
+ parentId: "reminder",
+ message: {
+ role: "developer",
+ content: [{ type: "text", text: "External instruction" }],
+ },
+ },
+ ]
+ .map((entry) => JSON.stringify(entry))
+ .join("\n"),
+ );
+ const events: AgentStreamEvent[] = [];
+ for await (const event of streamOmpHistory({ sessionFile, provider: "omp" }))
+ events.push(event);
+ expect(events.map((event) => event.item)).toEqual([
+ {
+ type: "assistant_message",
+ text: "[developer] External instruction",
+ messageId: "omp-custom-1",
+ },
+ ]);
+ });
+
test("coalesces replayed subagent poll calls by target set", async () => {
const events = await collectHistory([
{
@@ -223,12 +334,20 @@ describe("OMP history mapper", () => {
{
type: "timeline",
provider: "omp",
- item: { type: "assistant_message", text: "visible explicit custom" },
+ item: {
+ type: "assistant_message",
+ text: "visible explicit custom",
+ messageId: "omp-custom-1",
+ },
},
{
type: "timeline",
provider: "omp",
- item: { type: "assistant_message", text: "visible legacy custom" },
+ item: {
+ type: "assistant_message",
+ text: "visible legacy custom",
+ messageId: "omp-custom-2",
+ },
},
{
type: "timeline",
@@ -366,6 +485,17 @@ describe("OMP history mapper", () => {
},
},
});
+ // The running row must share the completed row's detail type, or the timeline merge keeps
+ // the running detail and drops the result.
+ expect(
+ events.flatMap((event) =>
+ event.type === "timeline" &&
+ event.item.type === "tool_call" &&
+ event.item.callId === "xd-write-call"
+ ? [event.item.detail.type]
+ : [],
+ ),
+ ).toEqual(["plain_text", "unknown"]);
});
test("maps only the active JSONL chain with native user ids and visible unknown roles", async () => {
@@ -447,8 +577,12 @@ describe("OMP history mapper", () => {
}
expect(events.map((event) => event.item)).toEqual([
{ type: "user_message", text: "active branch", messageId: "user-active" },
- { type: "assistant_message", text: "[future_control] Unsupported history record" },
- { type: "assistant_message", text: "[developer] developer note" },
+ {
+ type: "assistant_message",
+ text: "[future_control] Unsupported history record",
+ messageId: "omp-custom-1",
+ },
+ { type: "assistant_message", text: "[developer] developer note", messageId: "omp-custom-2" },
]);
const omp = new FakeOmp();
@@ -474,6 +608,163 @@ describe("OMP history mapper", () => {
]);
});
+ test("maps omp 18.1 custom_message entries like live custom messages", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "omp-custom-message-history-"));
+ const sessionFile = join(dir, "session.jsonl");
+ const skillPrompt =
+ '[IMPORTANT: User invoked the "commit" skill; follow its instructions. Full skill below.]\n\n# Commit';
+ const ircMessage = "\nworker-1 \nready for review \n ";
+ writeFileSync(
+ sessionFile,
+ [
+ { type: "session", id: "root", parentId: null },
+ {
+ type: "message",
+ id: "answer-1",
+ parentId: "root",
+ message: {
+ role: "assistant",
+ content: [{ type: "text", text: "Done." }],
+ responseId: "resp-1",
+ },
+ },
+ {
+ type: "custom_message",
+ customType: "skill-prompt",
+ content: skillPrompt,
+ display: true,
+ details: {
+ name: "commit",
+ path: "/home/me/.agents/skills/commit/SKILL.md",
+ lineCount: 12,
+ },
+ attribution: "user",
+ id: "skill-1",
+ parentId: "answer-1",
+ timestamp: "2026-09-13T13:11:35.811Z",
+ },
+ {
+ type: "custom_message",
+ customType: "irc:incoming",
+ content: ircMessage,
+ display: true,
+ details: { from: "worker-1", message: "ready for review" },
+ attribution: "user",
+ id: "irc-1",
+ parentId: "skill-1",
+ timestamp: "2026-09-13T13:11:36.811Z",
+ },
+ {
+ type: "custom_message",
+ customType: "hidden-reminder",
+ content: "must stay hidden",
+ display: false,
+ attribution: "user",
+ id: "hidden-1",
+ parentId: "irc-1",
+ timestamp: "2026-09-13T13:11:37.811Z",
+ },
+ {
+ type: "custom_message",
+ customType: "legacy-no-display",
+ content: "visible without display flag",
+ attribution: "user",
+ id: "legacy-1",
+ parentId: "hidden-1",
+ timestamp: "2026-09-13T13:11:38.811Z",
+ },
+ ]
+ .map((entry) => JSON.stringify(entry))
+ .join("\n"),
+ );
+
+ const events: AgentStreamEvent[] = [];
+ for await (const event of streamOmpHistory({ sessionFile, provider: "omp" })) {
+ events.push(event);
+ }
+ expect(events.map((event) => event.item)).toEqual([
+ { type: "assistant_message", text: "Done.", messageId: "resp-1" },
+ { type: "user_message", text: "/skill:commit", messageId: "omp-custom-skill-1-user" },
+ { type: "assistant_message", text: ircMessage, messageId: "omp-custom-irc-1" },
+ {
+ type: "assistant_message",
+ text: "visible without display flag",
+ messageId: "omp-custom-legacy-1",
+ },
+ ]);
+ });
+
+ test("synthesises the typed /skill bubble only for user-attributed skill prompts", async () => {
+ await expect(
+ collectHistory([
+ {
+ role: "custom",
+ content: '[IMPORTANT: User invoked the "improve" skill; follow its instructions.]',
+ customType: "skill-prompt",
+ display: true,
+ details: {
+ name: "improve",
+ path: "/home/me/.agents/skills/improve/SKILL.md",
+ lineCount: 9,
+ args: "tests",
+ },
+ attribution: "user",
+ id: "skill-args",
+ },
+ {
+ role: "custom",
+ content: '[IMPORTANT: Agent invoked the "improve" skill.]',
+ customType: "skill-prompt",
+ display: true,
+ details: {
+ name: "improve",
+ path: "/home/me/.agents/skills/improve/SKILL.md",
+ lineCount: 9,
+ },
+ attribution: "agent",
+ id: "skill-agent",
+ },
+ {
+ role: "custom",
+ content: "\nworker-1 \nhi \n ",
+ customType: "irc:incoming",
+ display: true,
+ details: { from: "worker-1", message: "hi" },
+ attribution: "user",
+ id: "irc-user",
+ },
+ ]),
+ ).resolves.toEqual([
+ {
+ type: "timeline",
+ provider: "omp",
+ item: {
+ type: "user_message",
+ text: "/skill:improve tests",
+ messageId: "omp-custom-skill-args-user",
+ },
+ },
+ {
+ type: "timeline",
+ provider: "omp",
+ item: {
+ type: "assistant_message",
+ text: '[IMPORTANT: Agent invoked the "improve" skill.]',
+ messageId: "omp-custom-skill-agent",
+ },
+ },
+ {
+ type: "timeline",
+ provider: "omp",
+ item: {
+ type: "assistant_message",
+ text: "\nworker-1 \nhi \n ",
+ messageId: "omp-custom-irc-user",
+ },
+ },
+ ]);
+ });
+
test("rehydrates structured batch and nested task transcripts with stable status and time", async () => {
const dir = mkdtempSync(join(tmpdir(), "omp-subagent-history-"));
const parentFile = join(dir, "parent.jsonl");
diff --git a/packages/server/src/server/agent/providers/omp/history.ts b/packages/server/src/server/agent/providers/omp/history.ts
index 7147e35e59..adca06a45d 100644
--- a/packages/server/src/server/agent/providers/omp/history.ts
+++ b/packages/server/src/server/agent/providers/omp/history.ts
@@ -1,12 +1,15 @@
import { readFile } from "node:fs/promises";
import { basename, extname, join } from "node:path";
-import type { AgentProvider, AgentStreamEvent } from "../../agent-sdk-types.js";
+import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { OmpHistoryMapper, type OmpCapturedUserMessageEntry } from "./message-history.js";
import type { OmpAgentMessage } from "./rpc-types.js";
import type { OmpRuntimeSession } from "./runtime.js";
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
+import type { OmpBridgedToolIdentity } from "./mcp-bridge.js";
import { formatOmpSubagentTitle } from "./subagent-title.js";
+import { mapOmpTodoPhases } from "./todo-mapper.js";
+import { OmpTodoPhaseSchema } from "./rpc-types.js";
interface OmpSessionEntry {
type?: string;
@@ -17,6 +20,24 @@ interface OmpSessionEntry {
[key: string]: unknown;
}
+export async function readOmpHistoryTodoState(
+ sessionFile: string,
+): Promise {
+ const entries = await readActiveOmpEntryChain(sessionFile).catch((error: unknown) => {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
+ throw error;
+ });
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
+ const message = entries[index]?.message;
+ if (message?.role !== "toolResult" || message.toolName !== "todo") continue;
+ const details = message.details;
+ if (!details || typeof details !== "object") return null;
+ const phases = OmpTodoPhaseSchema.array().safeParse(Reflect.get(details, "phases"));
+ return phases.success ? mapOmpTodoPhases(phases.data) : null;
+ }
+ return null;
+}
+
function extractOmpSubagentModel(entries: readonly OmpSessionEntry[]): string | null {
let resolvedModel: string | null = null;
for (const entry of entries) {
@@ -45,6 +66,7 @@ export async function* streamOmpHistory(input: {
sessionFile?: string;
runtimeSession?: OmpRuntimeSession;
provider: AgentProvider;
+ bridgedTools?: ReadonlyMap;
visitedSessionFiles?: Set;
}): AsyncGenerator {
if (!input.sessionFile) {
@@ -79,7 +101,12 @@ export async function* streamOmpHistory(input: {
userEntries.push({ id: entry.id, text: textOf(mapped.content) });
}
}
- const mapper = new OmpHistoryMapper(input.provider, userEntries, OMP_HISTORY_MAPPER_HOOKS);
+ const mapper = new OmpHistoryMapper(
+ input.provider,
+ userEntries,
+ OMP_HISTORY_MAPPER_HOOKS,
+ input.bridgedTools,
+ );
for (let index = 0; index < messages.length; index += 1) {
const timestamp = normalizeProviderReplayTimestamp(messageEntries[index]?.timestamp);
for (const event of mapper.mapMessages([messages[index]!])) {
@@ -326,17 +353,52 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null {
if (message.role === "system") {
return null;
}
+ if (message.role === "developer") {
+ const content = message.content;
+ if (
+ Array.isArray(content) &&
+ content.length === 1 &&
+ content[0]?.type === "text" &&
+ typeof content[0].text === "string" &&
+ content[0].text.trimStart().startsWith("") &&
+ content[0].text.trimEnd().endsWith(" ")
+ ) {
+ return null;
+ }
+ return visibleFallback(message.role, message);
+ }
if (["user", "assistant", "toolResult", "custom", "bashExecution"].includes(message.role)) {
return message as unknown as OmpAgentMessage;
}
return visibleFallback(message.role, message);
}
+ if (entry.type === "custom_message") {
+ return mapCustomMessageEntry(entry);
+ }
if (!entry.type || isControlEntryType(entry.type)) {
return null;
}
return visibleFallback(entry.type, entry);
}
+// omp 18.1+ persists injected rows (skill prompts, hub messages, job notices) as top-level
+// custom_message entries without a message object; replay them as live custom messages
+function mapCustomMessageEntry(entry: OmpSessionEntry): OmpAgentMessage | null {
+ const content = entry.content;
+ if (typeof content !== "string" && !Array.isArray(content)) {
+ return null;
+ }
+ return {
+ role: "custom",
+ content,
+ id: entry.id,
+ customType: entry.customType,
+ display: entry.display,
+ details: entry.details,
+ attribution: entry.attribution,
+ } as OmpAgentMessage;
+}
+
function isControlEntryType(type: string): boolean {
return (
type === "session" ||
@@ -357,6 +419,8 @@ function visibleFallback(role: string, value: Record): OmpAgent
let text = "Unsupported history record";
if (typeof value.content === "string") {
text = value.content;
+ } else if (Array.isArray(value.content)) {
+ text = textOf(value.content) || text;
} else if (typeof value.text === "string") {
text = value.text;
}
diff --git a/packages/server/src/server/agent/providers/omp/host-tools.test.ts b/packages/server/src/server/agent/providers/omp/host-tools.test.ts
index 70a795a5ee..61be9887c8 100644
--- a/packages/server/src/server/agent/providers/omp/host-tools.test.ts
+++ b/packages/server/src/server/agent/providers/omp/host-tools.test.ts
@@ -4,10 +4,9 @@ import { z } from "zod";
import type { PaseoToolCatalog, PaseoToolDefinition, PaseoToolResult } from "../../tools/types.js";
import {
- clearOmpHostToolState,
handleOmpHostToolRuntimeEvent,
+ OmpHostToolRouter,
serializeOmpHostTools,
- waitForOmpHostToolsIdle,
} from "./host-tools.js";
import type { OmpRpcHostToolResult } from "./rpc-types.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
@@ -33,11 +32,14 @@ class OmpHostToolHarness {
private readonly controlledStart = new Promise((resolve) => {
this.resolveControlledStart = resolve;
});
+ private readonly router: OmpHostToolRouter;
private constructor(
private readonly catalog: PaseoToolCatalog,
private readonly runtimeSession: Awaited>,
- ) {}
+ ) {
+ this.router = new OmpHostToolRouter({ runtimeSession, catalog, logger: this.logger });
+ }
static async withTools(tools: PaseoToolDefinition[]): Promise {
const omp = new FakeOmp();
@@ -69,7 +71,12 @@ class OmpHostToolHarness {
arguments: Record;
}): Promise {
const result = this.runtimeSession.nextHostToolResult();
- handleOmpHostToolRuntimeEvent({ type: "host_tool_call", ...input }, this.routerInput());
+ handleOmpHostToolRuntimeEvent(
+ { type: "host_tool_call", ...input },
+ this.router,
+ this.runtimeSession,
+ this.logger,
+ );
return await result;
}
@@ -82,7 +89,9 @@ class OmpHostToolHarness {
toolName: "wait_for_agent",
arguments: { agentId: "child-1" },
},
- this.routerInput(),
+ this.router,
+ this.runtimeSession,
+ this.logger,
);
}
@@ -93,7 +102,9 @@ class OmpHostToolHarness {
cancelControlledCall(): void {
handleOmpHostToolRuntimeEvent(
{ type: "host_tool_cancel", id: "cancel-1", targetId: "host-cancel" },
- this.routerInput(),
+ this.router,
+ this.runtimeSession,
+ this.logger,
);
}
@@ -103,7 +114,7 @@ class OmpHostToolHarness {
}
async waitForIdle(): Promise {
- await waitForOmpHostToolsIdle(this.runtimeSession);
+ await this.router.waitForIdle();
}
wasControlledCallAborted(): boolean {
@@ -119,11 +130,7 @@ class OmpHostToolHarness {
}
close(): void {
- clearOmpHostToolState(this.runtimeSession);
- }
-
- private routerInput() {
- return { runtimeSession: this.runtimeSession, paseoTools: this.catalog, logger: this.logger };
+ this.router.clear();
}
}
diff --git a/packages/server/src/server/agent/providers/omp/host-tools.ts b/packages/server/src/server/agent/providers/omp/host-tools.ts
index 9850c654dd..1b0f7a24a7 100644
--- a/packages/server/src/server/agent/providers/omp/host-tools.ts
+++ b/packages/server/src/server/agent/providers/omp/host-tools.ts
@@ -6,6 +6,7 @@ import {
} from "../../tools/paseo-tool-serialization.js";
import type { PaseoToolCatalog, PaseoToolResult } from "../../tools/types.js";
import type { OmpRuntimeSession } from "./runtime.js";
+import type { OmpMcpBridge } from "./mcp-bridge.js";
import {
OmpRpcHostToolCallRequestSchema,
OmpRpcHostToolCancelRequestSchema,
@@ -24,12 +25,11 @@ interface PendingOmpHostToolCall {
interface OmpHostToolRouterInput {
runtimeSession: OmpRuntimeSession;
- catalog: PaseoToolCatalog;
+ catalog?: PaseoToolCatalog;
+ bridge?: OmpMcpBridge;
logger: Logger;
}
-const routersByRuntimeSession = new WeakMap();
-
export function serializeOmpHostTools(catalog: PaseoToolCatalog): OmpRpcHostToolDefinition[] {
return [...catalog.tools.values()].map((tool) => {
const definition: OmpRpcHostToolDefinition = {
@@ -45,20 +45,11 @@ export function serializeOmpHostTools(catalog: PaseoToolCatalog): OmpRpcHostTool
});
}
-export async function setOmpHostTools(
- runtimeSession: OmpRuntimeSession,
- catalog: PaseoToolCatalog,
-): Promise {
- return await runtimeSession.setHostTools(serializeOmpHostTools(catalog));
-}
-
export function handleOmpHostToolRuntimeEvent(
event: unknown,
- input: {
- runtimeSession: OmpRuntimeSession;
- paseoTools?: PaseoToolCatalog;
- logger: Logger;
- },
+ router: OmpHostToolRouter | undefined,
+ runtimeSession: OmpRuntimeSession,
+ logger: Logger,
): boolean {
if (!isRecord(event) || typeof event.type !== "string" || !isOmpHostToolEventType(event.type)) {
return false;
@@ -66,9 +57,8 @@ export function handleOmpHostToolRuntimeEvent(
const call = OmpRpcHostToolCallRequestSchema.safeParse(event);
if (call.success) {
- const router = getRouter(input);
if (!router) {
- sendMissingCatalogResult(input.runtimeSession, call.data);
+ sendMissingCatalogResult(runtimeSession, call.data);
return true;
}
router.handleCall(call.data);
@@ -77,17 +67,17 @@ export function handleOmpHostToolRuntimeEvent(
const cancel = OmpRpcHostToolCancelRequestSchema.safeParse(event);
if (cancel.success) {
- getRouter(input)?.handleCancel(cancel.data.targetId);
+ router?.handleCancel(cancel.data.targetId);
return true;
}
const update = OmpRpcHostToolUpdateSchema.safeParse(event);
if (update.success) {
- input.logger.debug({ id: update.data.id }, "Ignoring unexpected inbound OMP host tool update");
+ logger.debug({ id: update.data.id }, "Ignoring unexpected inbound OMP host tool update");
return true;
}
- input.logger.debug({ event }, "Dropped malformed OMP host tool frame");
+ logger.debug({ event }, "Dropped malformed OMP host tool frame");
return true;
}
@@ -95,36 +85,6 @@ function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
-export function clearOmpHostToolState(runtimeSession: OmpRuntimeSession): void {
- routersByRuntimeSession.get(runtimeSession)?.clear();
- routersByRuntimeSession.delete(runtimeSession);
-}
-
-export async function waitForOmpHostToolsIdle(runtimeSession: OmpRuntimeSession): Promise {
- await routersByRuntimeSession.get(runtimeSession)?.waitForIdle();
-}
-
-function getRouter(input: {
- runtimeSession: OmpRuntimeSession;
- paseoTools?: PaseoToolCatalog;
- logger: Logger;
-}): OmpHostToolRouter | null {
- if (!input.paseoTools) {
- return null;
- }
- const existing = routersByRuntimeSession.get(input.runtimeSession);
- if (existing) {
- return existing;
- }
- const router = new OmpHostToolRouter({
- runtimeSession: input.runtimeSession,
- catalog: input.paseoTools,
- logger: input.logger,
- });
- routersByRuntimeSession.set(input.runtimeSession, router);
- return router;
-}
-
function sendMissingCatalogResult(
runtimeSession: OmpRuntimeSession,
request: OmpRpcHostToolCallRequest,
@@ -141,9 +101,10 @@ function isOmpHostToolEventType(type: string): boolean {
return type === "host_tool_call" || type === "host_tool_cancel" || type === "host_tool_update";
}
-class OmpHostToolRouter {
+export class OmpHostToolRouter {
private readonly runtimeSession: OmpRuntimeSession;
- private readonly catalog: PaseoToolCatalog;
+ private readonly catalog?: PaseoToolCatalog;
+ readonly bridge?: OmpMcpBridge;
private readonly logger: Logger;
private readonly pendingCalls = new Map();
private readonly idleWaiters = new Set<() => void>();
@@ -151,9 +112,17 @@ class OmpHostToolRouter {
constructor(input: OmpHostToolRouterInput) {
this.runtimeSession = input.runtimeSession;
this.catalog = input.catalog;
+ this.bridge = input.bridge;
this.logger = input.logger;
}
+ async register(): Promise {
+ return await this.runtimeSession.setHostTools([
+ ...(this.catalog ? serializeOmpHostTools(this.catalog) : []),
+ ...(this.bridge?.definitions ?? []),
+ ]);
+ }
+
handleCall(request: OmpRpcHostToolCallRequest): void {
const entry: PendingOmpHostToolCall = {
controller: new AbortController(),
@@ -183,6 +152,11 @@ class OmpHostToolRouter {
this.resolveIdleWaiters();
}
+ async close(): Promise {
+ this.clear();
+ await this.bridge?.close();
+ }
+
waitForIdle(): Promise {
if (this.pendingCalls.size === 0) return Promise.resolve();
return new Promise((resolve) => this.idleWaiters.add(resolve));
@@ -193,15 +167,20 @@ class OmpHostToolRouter {
entry: PendingOmpHostToolCall,
): Promise {
try {
- const result = await this.catalog.executeTool(request.toolName, request.arguments, {
- signal: entry.controller.signal,
- sendUpdate: (update) => {
- if (entry.canceled || entry.controller.signal.aborted) {
- return;
- }
- this.sendUpdate(request.id, update);
- },
- });
+ const result = this.bridge?.tool(request.toolName)
+ ? await this.bridge.execute(request.toolName, request.arguments, entry.controller.signal)
+ : ((await this.catalog?.executeTool(request.toolName, request.arguments, {
+ signal: entry.controller.signal,
+ sendUpdate: (update) => {
+ if (entry.canceled || entry.controller.signal.aborted) {
+ return;
+ }
+ this.sendUpdate(request.id, update);
+ },
+ })) ?? {
+ content: [{ type: "text", text: `Unknown host tool: ${request.toolName}` }],
+ isError: true,
+ });
if (entry.canceled || entry.controller.signal.aborted) {
return;
}
diff --git a/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts b/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts
index 5a380e553d..da86a38ba7 100644
--- a/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts
+++ b/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts
@@ -26,7 +26,7 @@ describe("mapOmpModel thinking options", () => {
const result = mapOmpModel(model, "omp");
- expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["high", "xhigh"]);
+ expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["auto", "high", "xhigh"]);
expect(result.defaultThinkingOptionId).toBe("xhigh");
expect(result.thinkingOptions?.find((option) => option.isDefault)?.id).toBe("xhigh");
});
@@ -37,6 +37,7 @@ describe("mapOmpModel thinking options", () => {
const result = mapOmpModel(model, "omp");
expect(result.thinkingOptions?.map((option) => option.id)).toEqual([
+ "auto",
"off",
"minimal",
"low",
@@ -57,6 +58,7 @@ describe("mapOmpModel thinking options", () => {
const result = mapOmpModel(model, "omp");
expect(result.thinkingOptions?.map((option) => option.id)).toEqual([
+ "auto",
"off",
"minimal",
"low",
@@ -93,7 +95,7 @@ describe("mapOmpModel thinking options", () => {
const result = mapOmpModel(model, "omp");
- expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["low", "high"]);
+ expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["auto", "low", "high"]);
expect(result.defaultThinkingOptionId).toBe("low");
expect(result.thinkingOptions?.find((option) => option.isDefault)?.id).toBe("low");
});
@@ -106,7 +108,12 @@ describe("mapOmpModel thinking options", () => {
const result = mapOmpModel(model, "omp");
- expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["low", "medium", "high"]);
+ expect(result.thinkingOptions?.map((option) => option.id)).toEqual([
+ "auto",
+ "low",
+ "medium",
+ "high",
+ ]);
expect(result.defaultThinkingOptionId).toBe("low");
});
@@ -119,6 +126,7 @@ describe("mapOmpModel thinking options", () => {
const result = mapOmpModel(model, "omp");
expect(result.thinkingOptions?.map((option) => option.id)).toEqual([
+ "auto",
"off",
"minimal",
"low",
@@ -130,6 +138,22 @@ describe("mapOmpModel thinking options", () => {
expect(result.defaultThinkingOptionId).toBe("medium");
});
+ test("offers auto alongside reported efforts without making it the default", () => {
+ const model = baseModel({
+ reasoning: true,
+ thinking: { mode: "effort", efforts: ["low", "high"], defaultLevel: "high" },
+ });
+
+ const result = mapOmpModel(model, "omp");
+
+ expect(result.thinkingOptions?.at(0)).toEqual({
+ id: "auto",
+ label: "Auto",
+ description: "OMP picks the effort each turn",
+ });
+ expect(result.defaultThinkingOptionId).toBe("high");
+ });
+
test("preserves provider and model id in the mapped definition", () => {
const model = baseModel({
reasoning: true,
diff --git a/packages/server/src/server/agent/providers/omp/map-omp-model.ts b/packages/server/src/server/agent/providers/omp/map-omp-model.ts
index 6cffc77949..094b1adf35 100644
--- a/packages/server/src/server/agent/providers/omp/map-omp-model.ts
+++ b/packages/server/src/server/agent/providers/omp/map-omp-model.ts
@@ -13,6 +13,7 @@ export const OMP_THINKING_OPTIONS: ReadonlyArray<{
description: string;
isDefault?: boolean;
}> = [
+ { id: "auto", label: "Auto", description: "OMP picks the effort each turn" },
{ id: "off", label: "Off", description: "No extra reasoning" },
{ id: "minimal", label: "Minimal", description: "Light reasoning" },
{ id: "low", label: "Low", description: "Faster reasoning" },
@@ -82,8 +83,10 @@ function resolveOmpThinkingConfig(model: OmpModel): {
reportedDefault && filtered.some((option) => option.id === reportedDefault)
? reportedDefault
: (filtered[0]?.id ?? DEFAULT_OMP_THINKING_LEVEL);
+ // `auto` is a session-level OMP mode, never a per-model effort, so models do not list it.
+ const options = [OMP_THINKING_OPTIONS[0], ...filtered.filter((option) => option.id !== "auto")];
return {
- thinkingOptions: filtered.map((option) =>
+ thinkingOptions: options.map((option) =>
mapThinkingOption(option, option.id === defaultThinkingOptionId),
),
defaultThinkingOptionId,
diff --git a/packages/server/src/server/agent/providers/omp/mcp-bridge.test.ts b/packages/server/src/server/agent/providers/omp/mcp-bridge.test.ts
new file mode 100644
index 0000000000..20cab26fd5
--- /dev/null
+++ b/packages/server/src/server/agent/providers/omp/mcp-bridge.test.ts
@@ -0,0 +1,340 @@
+import { fileURLToPath } from "node:url";
+import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, test, vi } from "vitest";
+import { OmpHarness } from "./test-utils/omp-harness.js";
+import type { PaseoToolCatalog } from "../../tools/types.js";
+
+const fixture = fileURLToPath(new URL("./test-utils/echo-mcp-server.mjs", import.meta.url));
+const healthy = { type: "stdio" as const, command: process.execPath, args: [fixture] };
+
+describe("OMP MCP host tools", () => {
+ test("registers a stdio MCP tool and proxies its call", async () => {
+ await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
+ const omp = new OmpHarness();
+ await omp.start({ mcpServers: { local: healthy } });
+ try {
+ const tool = omp.registeredHostTools().at(-1)?.[0];
+ expect(tool?.name).toMatch(/^mcp_local__echo_secret/);
+ const runtime = omp.runtime();
+ const result = runtime.nextHostToolResult();
+ runtime.emit({
+ type: "host_tool_call",
+ id: "call-1",
+ toolCallId: "tool-1",
+ toolName: tool!.name,
+ arguments: { word: "hello" },
+ });
+ expect(await result).toMatchObject({
+ id: "call-1",
+ result: { content: [{ type: "text", text: "MANGO:hello" }] },
+ });
+ const failed = runtime.nextHostToolResult();
+ runtime.emit({
+ type: "host_tool_call",
+ id: "call-2",
+ toolCallId: "tool-2",
+ toolName: tool!.name,
+ arguments: { word: "FAIL" },
+ });
+ expect(await failed).toMatchObject({
+ id: "call-2",
+ isError: true,
+ result: { isError: true, content: [{ type: "text", text: "fixture failure" }] },
+ });
+ runtime.emit({
+ type: "tool_execution_start",
+ toolCallId: "tool-1",
+ toolName: tool!.name,
+ args: { word: "hello" },
+ });
+ runtime.emit({
+ type: "tool_execution_end",
+ toolCallId: "tool-1",
+ toolName: tool!.name,
+ result: { content: [{ type: "text", text: "MANGO:hello" }] },
+ });
+ expect(omp.timeline().at(-1)).toMatchObject({
+ type: "tool_call",
+ name: "local / echo_secret",
+ status: "completed",
+ detail: {
+ type: "unknown",
+ input: { word: "hello" },
+ output: { content: [{ type: "text", text: "MANGO:hello" }] },
+ },
+ });
+ runtime.emit({
+ type: "tool_execution_start",
+ toolCallId: "native-1",
+ toolName: "mcp__native_echo_secret",
+ args: { word: "native" },
+ });
+ runtime.emit({
+ type: "tool_execution_end",
+ toolCallId: "native-1",
+ toolName: "mcp__native_echo_secret",
+ result: { content: [{ type: "text", text: "native result" }] },
+ });
+ expect(omp.timeline().at(-1)).toMatchObject({
+ type: "tool_call",
+ name: "mcp__native_echo_secret",
+ detail: { type: "unknown", input: { word: "native" } },
+ });
+ } finally {
+ await omp.close();
+ }
+ });
+
+ test("skips a failed server and registers a healthy one", async () => {
+ await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
+ const omp = new OmpHarness();
+ await omp.start({
+ mcpServers: { broken: { type: "stdio", command: "/nonexistent/omp-mcp" }, local: healthy },
+ });
+ try {
+ expect(
+ omp
+ .registeredHostTools()
+ .at(-1)
+ ?.map((tool) => tool.name),
+ ).toEqual([expect.stringMatching(/^mcp_local__echo_secret/)]);
+ const runtime = omp.runtime();
+ const result = runtime.nextHostToolResult();
+ runtime.emit({
+ type: "host_tool_call",
+ id: "healthy-call",
+ toolCallId: "healthy-tool",
+ toolName: omp.registeredHostTools()[0]![0]!.name,
+ arguments: { word: "healthy" },
+ });
+ expect(await result).toMatchObject({
+ result: { content: [{ type: "text", text: "MANGO:healthy" }] },
+ });
+ } finally {
+ await omp.close();
+ }
+ });
+
+ test("starts with a healthy server when another never answers initialize", async () => {
+ await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
+ const omp = new OmpHarness();
+ const started = Date.now();
+ await omp.start({
+ mcpServers: {
+ silent: {
+ type: "stdio",
+ command: process.execPath,
+ args: ["-e", "process.stdin.resume();setInterval(()=>{},1<<30)"],
+ },
+ local: healthy,
+ },
+ });
+ try {
+ expect(Date.now() - started).toBeLessThan(15_000);
+ expect(
+ omp
+ .registeredHostTools()
+ .at(-1)
+ ?.map((tool) => tool.name),
+ ).toEqual([expect.stringMatching(/^mcp_local__echo_secret/)]);
+ } finally {
+ await omp.close();
+ }
+ }, 80_000);
+
+ test("passes runtime and launch env to stdio, then applies server env", async () => {
+ await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
+ const omp = new OmpHarness({
+ runtimeEnv: { OMP_MCP_RUNTIME_ENV: "runtime", OMP_MCP_PRECEDENCE: "runtime" },
+ });
+ await omp.start(
+ {
+ mcpServers: {
+ local: { ...healthy, env: { OMP_MCP_PRECEDENCE: "server" } },
+ },
+ },
+ undefined,
+ { PASEO_AGENT_ID: "agent-123", OMP_MCP_PRECEDENCE: "launch" },
+ );
+ try {
+ const runtime = omp.runtime();
+ const result = runtime.nextHostToolResult();
+ runtime.emit({
+ type: "host_tool_call",
+ id: "env-call",
+ toolCallId: "env-tool",
+ toolName: omp.registeredHostTools()[0]![0]!.name,
+ arguments: { word: "ENV" },
+ });
+ expect(await result).toMatchObject({
+ result: {
+ content: [
+ {
+ type: "text",
+ text: '{"runtime":"runtime","agent":"agent-123","precedence":"server"}',
+ },
+ ],
+ },
+ });
+ } finally {
+ await omp.close();
+ }
+ });
+
+ test("replays the bridge label and Codex-style detail after archive", async () => {
+ await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
+ const omp = new OmpHarness();
+ await omp.start({ mcpServers: { local: healthy } });
+ try {
+ const exposedName = omp.registeredHostTools()[0]![0]!.name;
+ const persisted = omp.persistence();
+ expect(persisted?.metadata?.bridgedTools).toEqual([
+ [exposedName, { server: "local", tool: "echo_secret" }],
+ ]);
+ const directory = await mkdtemp(join(tmpdir(), "omp-mcp-history-"));
+ const sessionFile = join(directory, "session.jsonl");
+ await writeFile(
+ sessionFile,
+ [
+ { type: "session", id: "root", parentId: null },
+ {
+ type: "message",
+ id: "call",
+ parentId: "root",
+ message: {
+ role: "assistant",
+ content: [
+ {
+ type: "toolCall",
+ id: "tool-1",
+ name: exposedName,
+ arguments: { word: "REVIEW" },
+ },
+ ],
+ },
+ },
+ {
+ type: "message",
+ id: "result",
+ parentId: "call",
+ message: {
+ role: "toolResult",
+ toolCallId: "tool-1",
+ toolName: exposedName,
+ content: [{ type: "text", text: "MANGO:REVIEW" }],
+ },
+ },
+ ]
+ .map((entry) => JSON.stringify(entry))
+ .join("\n"),
+ );
+ const events = await omp.replayHistory({ ...persisted!, nativeHandle: sessionFile });
+ expect(events.findLast((event) => event.type === "timeline")?.item).toMatchObject({
+ type: "tool_call",
+ name: "local / echo_secret",
+ detail: {
+ type: "unknown",
+ input: { word: "REVIEW" },
+ output: { content: [{ type: "text", text: "MANGO:REVIEW" }] },
+ },
+ });
+ } finally {
+ await omp.close();
+ }
+ });
+
+ test("registers Paseo and MCP tools in the same replacement set", async () => {
+ await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
+ const paseoTools: PaseoToolCatalog = {
+ tools: new Map([
+ [
+ "create_agent",
+ {
+ name: "create_agent",
+ description: "Create an agent",
+ handler: async () => ({ content: [] }),
+ },
+ ],
+ ]),
+ getTool: () => undefined,
+ executeTool: async () => ({ content: [] }),
+ };
+ const omp = new OmpHarness();
+ await omp.start({ mcpServers: { local: healthy } }, paseoTools);
+ try {
+ expect(omp.registeredHostTools()).toHaveLength(1);
+ expect(omp.registeredHostTools()[0]?.map((tool) => tool.name)).toEqual([
+ "create_agent",
+ expect.stringMatching(/^mcp_local__echo_secret_/),
+ ]);
+ } finally {
+ await omp.close();
+ }
+ });
+
+ test("registers MCP tools after resume and crash relaunch", async () => {
+ await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
+ const omp = new OmpHarness();
+ await omp.resume(
+ { user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } },
+ { mcpServers: { local: healthy } },
+ );
+ try {
+ expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
+ omp.processExit("crashed");
+ await omp.startTurn("again");
+ await vi.waitFor(() => {
+ expect(omp.runtimeSessions()).toHaveLength(2);
+ expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
+ });
+ } finally {
+ await omp.close();
+ }
+ });
+
+ test("closes the stdio MCP child with the session", async () => {
+ await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
+ const directory = await mkdtemp(join(tmpdir(), "omp-mcp-pid-"));
+ const pidFile = join(directory, "pid");
+ const omp = new OmpHarness();
+ await omp.start({ mcpServers: { local: { ...healthy, env: { OMP_MCP_PID_FILE: pidFile } } } });
+ const pid = Number(await readFile(pidFile, "utf8"));
+ expect(() => process.kill(pid, 0)).not.toThrow();
+ await omp.close();
+ expect(() => process.kill(pid, 0)).toThrow();
+ });
+
+ test("cancels an in-flight MCP call when OMP cancels its host tool", async () => {
+ await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
+ const directory = await mkdtemp(join(tmpdir(), "omp-mcp-cancel-"));
+ const waitFile = join(directory, "waiting");
+ const cancelFile = join(directory, "cancelled");
+ const omp = new OmpHarness();
+ await omp.start({
+ mcpServers: {
+ local: {
+ ...healthy,
+ env: { OMP_MCP_WAIT_FILE: waitFile, OMP_MCP_CANCEL_FILE: cancelFile },
+ },
+ },
+ });
+ try {
+ const runtime = omp.runtime();
+ runtime.emit({
+ type: "host_tool_call",
+ id: "wait-call",
+ toolCallId: "wait-tool",
+ toolName: omp.registeredHostTools()[0]![0]!.name,
+ arguments: { word: "WAIT" },
+ });
+ await vi.waitFor(async () => expect(await readFile(waitFile, "utf8")).toBe("waiting"));
+ runtime.emit({ type: "host_tool_cancel", id: "cancel-1", targetId: "wait-call" });
+ await vi.waitFor(async () => expect(await readFile(cancelFile, "utf8")).toBe("cancelled"));
+ expect(runtime.hostToolResults).toEqual([]);
+ } finally {
+ await omp.close();
+ }
+ });
+});
diff --git a/packages/server/src/server/agent/providers/omp/mcp-bridge.ts b/packages/server/src/server/agent/providers/omp/mcp-bridge.ts
new file mode 100644
index 0000000000..72f7a093f4
--- /dev/null
+++ b/packages/server/src/server/agent/providers/omp/mcp-bridge.ts
@@ -0,0 +1,142 @@
+import { createHash } from "node:crypto";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
+import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
+import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
+import type { Logger } from "pino";
+import type { McpServerConfig } from "../../agent-sdk-types.js";
+import type { PaseoToolResult } from "../../tools/types.js";
+import type { OmpRpcHostToolDefinition } from "./rpc-types.js";
+
+interface BridgedTool {
+ client: Client;
+ server: string;
+ tool: string;
+}
+
+export interface OmpBridgedToolIdentity {
+ server: string;
+ tool: string;
+}
+
+const SERVER_TIMEOUT_MS = 10_000;
+
+export class OmpMcpBridge {
+ readonly definitions: OmpRpcHostToolDefinition[] = [];
+ private readonly tools = new Map();
+ private readonly clients: Client[] = [];
+ private closed = false;
+
+ static async connect(
+ servers: Record | undefined,
+ cwd: string,
+ logger: Logger,
+ env: NodeJS.ProcessEnv,
+ ): Promise {
+ const bridge = new OmpMcpBridge();
+ const connections = await Promise.all(
+ Object.entries(servers ?? {}).map(async ([serverName, config]) => {
+ const client = new Client({ name: "paseo-omp-mcp", version: "1.0.0" });
+ try {
+ const transport = makeTransport(config, cwd, env);
+ await client.connect(transport, { timeout: SERVER_TIMEOUT_MS });
+ const listed = await client.listTools(undefined, { timeout: SERVER_TIMEOUT_MS });
+ return { serverName, client, tools: listed.tools };
+ } catch (error) {
+ logger.warn({ err: error, server: serverName }, "OMP MCP server unavailable");
+ await client.close().catch(() => undefined);
+ return null;
+ }
+ }),
+ );
+ for (const connection of connections) {
+ if (!connection) continue;
+ const { serverName, client, tools } = connection;
+ bridge.clients.push(client);
+ for (const tool of tools) {
+ const name = bridgeName(serverName, tool.name);
+ bridge.tools.set(name, { client, server: serverName, tool: tool.name });
+ bridge.definitions.push({
+ name,
+ label: `${serverName} / ${tool.name}`,
+ description: tool.description || `${tool.name} from ${serverName}`,
+ loadMode: "essential",
+ parameters: tool.inputSchema,
+ });
+ }
+ }
+ return bridge;
+ }
+
+ tool(name: string): OmpBridgedToolIdentity | undefined {
+ const entry = this.tools.get(name);
+ return entry ? { server: entry.server, tool: entry.tool } : undefined;
+ }
+
+ toolIdentities(): Map {
+ return new Map(
+ [...this.tools].map(([name, entry]) => [name, { server: entry.server, tool: entry.tool }]),
+ );
+ }
+
+ async execute(
+ name: string,
+ args: Record,
+ signal: AbortSignal,
+ ): Promise {
+ const entry = this.tools.get(name);
+ if (!entry || this.closed) throw new Error(`MCP tool ${name} is unavailable`);
+ const raw = await entry.client.callTool({ name: entry.tool, arguments: args }, undefined, {
+ signal,
+ });
+ const result = CallToolResultSchema.parse(raw);
+ return {
+ content: result.content.map((item) => ({ ...item })),
+ ...(result.structuredContent !== undefined
+ ? { structuredContent: result.structuredContent }
+ : {}),
+ ...(result.isError !== undefined ? { isError: result.isError } : {}),
+ };
+ }
+
+ async close(): Promise {
+ if (this.closed) return;
+ this.closed = true;
+ await Promise.all(this.clients.map((client) => client.close().catch(() => undefined)));
+ this.clients.length = 0;
+ }
+}
+
+function makeTransport(config: McpServerConfig, cwd: string, env: NodeJS.ProcessEnv) {
+ if (config.type === "stdio") {
+ return new StdioClientTransport({
+ command: config.command,
+ args: config.args,
+ cwd,
+ env: { ...env, ...config.env } as Record,
+ stderr: "ignore",
+ });
+ }
+ if (config.type === "http") {
+ return new StreamableHTTPClientTransport(new URL(config.url), {
+ requestInit: { headers: config.headers },
+ });
+ }
+ return new SSEClientTransport(new URL(config.url), {
+ requestInit: { headers: config.headers },
+ eventSourceInit: {
+ fetch: (url, init) =>
+ fetch(url, { ...init, headers: { ...init?.headers, ...config.headers } }),
+ },
+ });
+}
+
+function bridgeName(server: string, tool: string): string {
+ const key = `${server}\0${tool}`;
+ const hash = createHash("sha256").update(key).digest("hex").slice(0, 12);
+ const clean = (name: string, length: number) =>
+ name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, length);
+ // 64 characters also fits the stricter model tool-name limits OMP can reach.
+ return `mcp_${clean(server, 20)}__${clean(tool, 25)}_${hash}`;
+}
diff --git a/packages/server/src/server/agent/providers/omp/message-history.ts b/packages/server/src/server/agent/providers/omp/message-history.ts
index b030ec513c..112a942b64 100644
--- a/packages/server/src/server/agent/providers/omp/message-history.ts
+++ b/packages/server/src/server/agent/providers/omp/message-history.ts
@@ -1,12 +1,18 @@
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpAgentMessage, OmpImageContent, OmpTextContent } from "./rpc-types.js";
-import { shouldDisplayOmpCustomMessage } from "./custom-message.js";
+import type { OmpBridgedToolIdentity } from "./mcp-bridge.js";
+import {
+ ompCustomMessageId,
+ ompSkillPromptUserText,
+ shouldDisplayOmpCustomMessage,
+} from "./custom-message.js";
import {
- extractTextFromToolResult,
mapToolDetail,
parseToolArgs,
parseToolResult,
resolveToolCallName,
+ toolFailureMessage,
+ isOmpToolFailure,
type OmpToolResult,
type OmpTrackedToolCall,
} from "./tool-call-detail.js";
@@ -26,7 +32,7 @@ export interface OmpHistoryMapperHooks {
mapToolDetail?: (
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
- context: { toolCallId: string },
+ context: { toolCallId: string; bridgedTool?: OmpBridgedToolIdentity },
) => ToolCallDetail | null;
}
@@ -58,11 +64,13 @@ export class OmpHistoryMapper {
private readonly pendingToolCalls = new Map();
private userIndex = 0;
private assistantIndex = 0;
+ private customIndex = 0;
constructor(
private readonly provider: string,
private readonly userEntries: readonly OmpCapturedUserMessageEntry[] = [],
private readonly hooks: OmpHistoryMapperHooks = {},
+ private readonly bridgedTools: ReadonlyMap = new Map(),
) {}
mapMessages(messages: readonly OmpAgentMessage[]): AgentStreamEvent[] {
@@ -126,15 +134,30 @@ export class OmpHistoryMapper {
if (mappedEvent) {
return [mappedEvent];
}
- return text
- ? [
- {
- type: "timeline",
- provider: this.provider,
- item: { type: "assistant_message", text },
- },
- ]
- : [];
+ if (!text) {
+ return [];
+ }
+ const messageId = ompCustomMessageId(message, () => {
+ this.customIndex += 1;
+ return this.customIndex;
+ });
+ const skillPrompt = ompSkillPromptUserText(message);
+ if (skillPrompt) {
+ return [
+ {
+ type: "timeline",
+ provider: this.provider,
+ item: { type: "user_message", text: skillPrompt, messageId: `${messageId}-user` },
+ },
+ ];
+ }
+ return [
+ {
+ type: "timeline",
+ provider: this.provider,
+ item: { type: "assistant_message", text, messageId },
+ },
+ ];
}
private mapAssistantMessage(
@@ -174,7 +197,7 @@ export class OmpHistoryMapper {
item: {
type: "tool_call",
callId: this.resolveToolCallId(content.id, tracked),
- name: tracked.toolName,
+ name: this.toolName(tracked, null),
status: "running",
detail,
error: null,
@@ -201,10 +224,10 @@ export class OmpHistoryMapper {
provider: this.provider,
item: toToolResultTimelineItem({
callId: this.resolveToolCallId(message.toolCallId, tracked),
- name: resolveToolCallName(tracked, result),
- isError: Boolean(message.isError),
+ name: this.toolName(tracked, result),
+ isError: isOmpToolFailure(tracked, result, Boolean(message.isError)),
detail,
- errorText: extractTextFromToolResult(result) ?? "Tool call failed",
+ errorText: toolFailureMessage(result),
}),
};
}
@@ -242,7 +265,17 @@ export class OmpHistoryMapper {
result: OmpToolResult,
): ToolCallDetail | null {
const hook = this.hooks.mapToolDetail;
- return hook ? hook(toolCall, result, { toolCallId }) : mapToolDetail(toolCall, result);
+ return hook
+ ? hook(toolCall, result, {
+ toolCallId,
+ bridgedTool: this.bridgedTools.get(toolCall.toolName),
+ })
+ : mapToolDetail(toolCall, result);
+ }
+
+ private toolName(toolCall: OmpTrackedToolCall, result: OmpToolResult): string {
+ const bridged = this.bridgedTools.get(toolCall.toolName);
+ return bridged ? `${bridged.server} / ${bridged.tool}` : resolveToolCallName(toolCall, result);
}
}
diff --git a/packages/server/src/server/agent/providers/omp/provider-config.ts b/packages/server/src/server/agent/providers/omp/provider-config.ts
index 6c360cbc60..2fe5067845 100644
--- a/packages/server/src/server/agent/providers/omp/provider-config.ts
+++ b/packages/server/src/server/agent/providers/omp/provider-config.ts
@@ -6,7 +6,6 @@ import { z } from "zod";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
-const OMP_SESSION_DIR = "~/.omp/agent/sessions";
const DEFAULT_OMP_MODE_ID = "full";
const DEFAULT_OMP_READY_TIMEOUT_MS = 20_000;
const DEFAULT_OMP_RPC_TIMEOUT_MS = 60_000;
@@ -25,7 +24,7 @@ export const OmpProviderParamsSchema = z
.strict();
export interface OmpRuntimeProviderParams {
- sessionDir: string;
+ sessionDir?: string;
readyTimeoutMs: number;
rpcTimeoutMs: number;
}
@@ -137,7 +136,7 @@ export function resolveOmpProviderParams(providerParams: unknown): {
const configuredRpcTimeoutMs = params.rpcTimeoutMs;
return {
runtimeProviderParams: {
- sessionDir: params.sessionDir ?? OMP_SESSION_DIR,
+ sessionDir: params.sessionDir,
readyTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_READY_TIMEOUT_MS,
rpcTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_RPC_TIMEOUT_MS,
},
diff --git a/packages/server/src/server/agent/providers/omp/question-ui.ts b/packages/server/src/server/agent/providers/omp/question-ui.ts
new file mode 100644
index 0000000000..4fdb37f61c
--- /dev/null
+++ b/packages/server/src/server/agent/providers/omp/question-ui.ts
@@ -0,0 +1,355 @@
+import type {
+ AgentPermissionRequest,
+ AgentPermissionResponse,
+ AgentProvider,
+} from "../../agent-sdk-types.js";
+import type { OmpAgentMessage, OmpRuntimeEvent } from "./rpc-types.js";
+
+type UiRequest = Extract;
+interface UiResponse {
+ value?: string;
+ confirmed?: boolean;
+ cancelled?: boolean;
+}
+type SendResponse = (id: string, response: UiResponse) => void;
+
+const OTHER = "Other (type your own)";
+const ANSWER_HEADER = "Response";
+
+interface SelectOption {
+ label: string;
+ description?: string;
+}
+
+interface AskQuestion {
+ title: string;
+ multi: boolean;
+ options: SelectOption[];
+}
+
+interface PendingAnswer {
+ question: AskQuestion;
+ selected: string[];
+ custom: string | null;
+ next: "select" | "editor";
+}
+
+function record(value: unknown): Record | null {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+function string(value: unknown): string | undefined {
+ return typeof value === "string" ? value : undefined;
+}
+
+function readSelectOptions(event: UiRequest): SelectOption[] {
+ const details = Array.isArray(event.optionDetails) ? event.optionDetails : [];
+ return (event.options ?? []).map((label, index) => {
+ const description = string(record(details[index])?.description);
+ return description?.trim() ? { label, description } : { label };
+ });
+}
+
+function isOptionalInput(placeholder: string | undefined): boolean {
+ return /\boptional\b|\bskip\b/i.test(placeholder ?? "");
+}
+
+function inputTitle(title: string | undefined, placeholder: string | undefined): string {
+ if (!isOptionalInput(placeholder)) return title ?? "Enter a value";
+ return /\bcomment\b/i.test(`${title ?? ""}\n${placeholder ?? ""}`)
+ ? "Optional comment"
+ : "Optional response";
+}
+
+function genericQuestion(
+ event: UiRequest,
+ provider: AgentProvider,
+ title: string,
+ options: SelectOption[],
+ input: { placeholder?: string; allowEmpty?: boolean; dismissLabel?: string } = {},
+): AgentPermissionRequest {
+ return {
+ id: event.id,
+ provider,
+ name: `OMP ${event.method}`,
+ kind: "question",
+ title,
+ input: {
+ questions: [
+ { question: title, header: ANSWER_HEADER, options, multiSelect: false, ...input },
+ ],
+ },
+ metadata: { extensionUiMethod: event.method, answerHeader: ANSWER_HEADER },
+ };
+}
+
+function mapGenericQuestion(
+ event: UiRequest,
+ provider: AgentProvider,
+): AgentPermissionRequest | null {
+ switch (event.method) {
+ case "select":
+ return genericQuestion(
+ event,
+ provider,
+ event.title ?? "Select an option",
+ readSelectOptions(event),
+ );
+ case "input": {
+ const placeholder = event.placeholder;
+ const optional = isOptionalInput(placeholder);
+ return genericQuestion(event, provider, inputTitle(event.title, placeholder), [], {
+ ...(placeholder ? { placeholder } : {}),
+ ...(optional ? { allowEmpty: true, dismissLabel: "Skip" } : {}),
+ });
+ }
+ case "editor":
+ return genericQuestion(event, provider, event.title ?? "Edit text", []);
+ case "confirm":
+ return genericQuestion(
+ event,
+ provider,
+ [event.title, event.message].filter(Boolean).join("\n\n"),
+ [{ label: "Yes" }, { label: "No" }],
+ );
+ default:
+ return null;
+ }
+}
+
+function genericResponse(
+ request: AgentPermissionRequest,
+ response: AgentPermissionResponse,
+): UiResponse {
+ if (response.behavior === "deny") return { cancelled: true };
+ const answers = record(response.updatedInput?.answers);
+ const answer = Object.values(answers ?? {}).find(
+ (value): value is string => typeof value === "string",
+ );
+ if (answer === undefined) return { cancelled: true };
+ return request.metadata?.extensionUiMethod === "confirm"
+ ? { confirmed: /^yes$/i.test(answer.trim()) }
+ : { value: answer };
+}
+
+function questionTitle(title: string): string {
+ return title.replace(/^\(\d+ selected\) /, "");
+}
+
+function matchesQuestion(title: string, question: AskQuestion): boolean {
+ const plainTitle = questionTitle(title);
+ if (plainTitle === question.title) return true;
+ return (
+ plainTitle.startsWith(question.title) &&
+ /^ \(\d+\/\d+\)$/.test(plainTitle.slice(question.title.length))
+ );
+}
+
+function readQuestions(args: unknown): AskQuestion[] {
+ const questions = record(args)?.questions;
+ if (!Array.isArray(questions)) return [];
+ return questions.flatMap((item) => {
+ const question = record(item);
+ if (typeof question?.question !== "string" || !Array.isArray(question.options)) return [];
+ const options = question.options.flatMap((optionValue) => {
+ const option = record(optionValue);
+ if (typeof option?.label !== "string") return [];
+ return [
+ {
+ label: option.label,
+ ...(typeof option.description === "string" ? { description: option.description } : {}),
+ },
+ ];
+ });
+ return [{ title: question.question, multi: question.multi === true, options }];
+ });
+}
+
+function selectedAnswer(
+ answer: string,
+ labels: string[],
+ multi: boolean,
+): { selected: string[]; custom: string | null } {
+ if (!multi)
+ return labels.includes(answer)
+ ? { selected: [answer], custom: null }
+ : { selected: [], custom: answer };
+
+ // The shared question UI sends a comma-joined answer in click order. Consume
+ // exact option labels from the front; any remaining text is the Other answer.
+ let remaining = answer;
+ const selected: string[] = [];
+ while (remaining.length > 0) {
+ const label = labels
+ .filter((candidate) => !selected.includes(candidate))
+ .sort((left, right) => right.length - left.length)
+ .find((candidate) => remaining === candidate || remaining.startsWith(`${candidate}, `));
+ if (!label) break;
+ selected.push(label);
+ remaining = remaining === label ? "" : remaining.slice(label.length + 2);
+ }
+ return { selected, custom: remaining || null };
+}
+
+/** Owns OMP question mapping and the ask tool's multi-RPC answer sequence. */
+export class OmpQuestionUi {
+ private questions: AskQuestion[] = [];
+ private pending: PendingAnswer | null = null;
+
+ observeMessage(message: OmpAgentMessage): void {
+ if (message.role !== "assistant") return;
+ for (const item of message.content) {
+ if (item.type === "toolCall" && item.name === "ask") this.start(item.name, item.arguments);
+ }
+ }
+
+ start(toolName: string, args: unknown): void {
+ if (toolName === "ask") {
+ this.questions = readQuestions(args);
+ this.pending = null;
+ }
+ }
+
+ finish(toolName: string): void {
+ if (toolName === "ask") {
+ this.questions = [];
+ this.pending = null;
+ }
+ }
+
+ handleRequest(
+ event: UiRequest,
+ provider: AgentProvider,
+ send: SendResponse,
+ ): AgentPermissionRequest | null {
+ if (this.consume(event, send)) return null;
+ return this.mapAsk(event, provider) ?? mapGenericQuestion(event, provider);
+ }
+
+ private mapAsk(event: UiRequest, provider: AgentProvider): AgentPermissionRequest | null {
+ if (event.method !== "select" || typeof event.title !== "string") return null;
+ const title = event.title;
+ const question = this.questions.find((item) => matchesQuestion(title, item));
+ if (!question || !Array.isArray(event.options) || !event.options.includes(OTHER)) return null;
+
+ const descriptions = new Map(
+ question.options.map((option) => [option.label, option.description]),
+ );
+ const options = readSelectOptions(event)
+ .filter((option) => option.label !== OTHER && !option.label.endsWith(" Done selecting"))
+ .map((option) => {
+ const description =
+ descriptions.get(option.label) ??
+ descriptions.get(option.label.replace(/ \(Recommended\)$/, "")) ??
+ option.description;
+ return description ? { label: option.label, description } : { label: option.label };
+ });
+ return {
+ id: event.id,
+ provider,
+ name: "OMP ask",
+ kind: "question",
+ title: question.title,
+ input: {
+ questions: [
+ {
+ question: question.title,
+ header: ANSWER_HEADER,
+ options,
+ multiSelect: question.multi,
+ allowOther: true,
+ },
+ ],
+ },
+ metadata: {
+ ompAsk: true,
+ answerHeader: ANSWER_HEADER,
+ optionLabels: options.map((option) => option.label),
+ },
+ };
+ }
+
+ respond(
+ request: AgentPermissionRequest,
+ response: AgentPermissionResponse,
+ send: SendResponse,
+ ): void {
+ if (request.metadata?.ompAsk !== true) {
+ send(request.id, genericResponse(request, response));
+ return;
+ }
+ const question = this.questions.find((item) => item.title === request.title);
+ const answers = record(
+ response.behavior === "allow" ? response.updatedInput?.answers : undefined,
+ );
+ const answer = answers?.[ANSWER_HEADER];
+ if (response.behavior === "deny" || typeof answer !== "string" || !question) {
+ this.pending = null;
+ send(request.id, { cancelled: true });
+ return;
+ }
+
+ const labels = Array.isArray(request.metadata.optionLabels)
+ ? request.metadata.optionLabels.filter((item): item is string => typeof item === "string")
+ : [];
+ const { selected, custom } = selectedAnswer(answer, labels, question.multi);
+ if (selected.length === 0 && custom === null) {
+ this.pending = null;
+ send(request.id, { cancelled: true });
+ return;
+ }
+ if (question.multi) {
+ this.pending = { question, selected: selected.slice(1), custom, next: "select" };
+ send(request.id, { value: selected[0] ?? OTHER });
+ if (selected.length === 0) this.pending.next = "editor";
+ } else if (custom !== null) {
+ this.pending = { question, selected: [], custom, next: "editor" };
+ send(request.id, { value: OTHER });
+ } else {
+ this.pending = null;
+ send(request.id, { value: selected[0] });
+ }
+ }
+
+ consume(event: UiRequest, send: SendResponse): boolean {
+ const pending = this.pending;
+ if (pending?.next === "editor" && event.method === "editor" && pending.custom !== null) {
+ this.pending = null;
+ send(event.id, { value: pending.custom });
+ return true;
+ }
+ if (
+ !pending ||
+ typeof event.title !== "string" ||
+ !matchesQuestion(event.title, pending.question)
+ )
+ return false;
+ if (pending.next !== "select" || event.method !== "select" || !Array.isArray(event.options))
+ return false;
+
+ const choice = pending.selected[0];
+ if (choice && event.options.includes(choice)) {
+ pending.selected.shift();
+ send(event.id, { value: choice });
+ return true;
+ }
+ if (pending.custom !== null && event.options.includes(OTHER)) {
+ pending.next = "editor";
+ send(event.id, { value: OTHER });
+ return true;
+ }
+ const done = event.options.find((option) => option.endsWith(" Done selecting"));
+ if (!done) {
+ // OMP omits Done for multi-question asks and expects a right-arrow
+ // navigation callback, which its RPC response cannot express.
+ this.pending = null;
+ send(event.id, { cancelled: true });
+ return true;
+ }
+ this.pending = null;
+ send(event.id, { value: done });
+ return true;
+ }
+}
diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts
index 9cc8bc340b..4c69f2fbeb 100644
--- a/packages/server/src/server/agent/providers/omp/rpc-types.ts
+++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts
@@ -8,6 +8,7 @@ export const OmpThinkingLevelSchema = z.enum([
"high",
"xhigh",
"max",
+ "auto",
]);
export const OmpImageContentSchema = z
@@ -128,6 +129,8 @@ export const OmpSessionStateSchema = z
isStreaming: z.boolean(),
isCompacting: z.boolean(),
autoCompactionEnabled: z.boolean().optional(),
+ fastModeEnabled: z.boolean().optional(),
+ fastModeActive: z.boolean().optional(),
sessionFile: z.string().optional(),
sessionId: z.string(),
sessionName: z.string().optional(),
@@ -333,7 +336,8 @@ export const OmpAgentSessionEventSchema = z.discriminatedUnion("type", [
export const OmpTodoItemSchema = z
.object({
content: z.string(),
- status: z.enum(["pending", "in_progress", "completed", "abandoned"]),
+ status: z.enum(["pending", "in_progress", "completed", "abandoned", "blocked"]),
+ blocker: z.string().optional(),
})
.passthrough();
export const OmpTodoPhaseSchema = z
@@ -495,6 +499,7 @@ export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [
OmpRpcHostToolCallRequestSchema,
OmpRpcHostToolCancelRequestSchema,
OmpRpcHostToolUpdateSchema,
+ z.object({ type: z.literal("model_changed") }).passthrough(),
]);
const OmpCommandBase = { id: z.string().optional() };
@@ -513,6 +518,13 @@ export const OmpRpcCommandSchema = z.discriminatedUnion("type", [
z.object({ ...OmpCommandBase, type: z.literal("set_auto_compaction"), enabled: z.boolean() }),
z.object({ ...OmpCommandBase, type: z.literal("abort") }),
z.object({ ...OmpCommandBase, type: z.literal("get_state") }),
+ z.object({
+ ...OmpCommandBase,
+ type: z.literal("steer"),
+ message: z.string(),
+ images: z.array(OmpImageContentSchema).optional(),
+ }),
+ z.object({ ...OmpCommandBase, type: z.literal("set_fast_mode"), enabled: z.boolean() }),
z.object({ ...OmpCommandBase, type: z.literal("get_messages") }),
z.object({ ...OmpCommandBase, type: z.literal("get_available_models") }),
z.object({
diff --git a/packages/server/src/server/agent/providers/omp/runtime.ts b/packages/server/src/server/agent/providers/omp/runtime.ts
index dc8001e3ec..2381610ff6 100644
--- a/packages/server/src/server/agent/providers/omp/runtime.ts
+++ b/packages/server/src/server/agent/providers/omp/runtime.ts
@@ -52,6 +52,7 @@ export interface OmpRuntimeSession {
setAutoCompaction(enabled: boolean): Promise;
abort(): Promise;
getState(): Promise;
+ setFastMode(enabled: boolean): Promise<{ enabled: boolean; active: boolean }>;
getMessages(): Promise;
getAvailableModels(timeoutMs?: number | null): Promise;
setModel(provider: string, modelId: string): Promise;
@@ -65,7 +66,10 @@ export interface OmpRuntimeSession {
branch(entryId: string): Promise<{ text: string }>;
getBranchMessages(): Promise>;
activeBranchEntryId?: string;
- steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void;
+ steer(
+ message: string,
+ images?: Array<{ type: "image"; data: string; mimeType: string }>,
+ ): Promise;
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
diff --git a/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts b/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts
index 9dc4ba876c..916c61e042 100644
--- a/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts
+++ b/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts
@@ -3,7 +3,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
-import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js";
+import {
+ listOmpImportableSessions,
+ readOmpImportSessionConfig,
+ resolveOmpSessionFile,
+} from "./session-descriptor.js";
async function writeSession(root: string, relativePath: string, lines: unknown[]): Promise {
const filePath = path.join(root, "sessions", relativePath);
@@ -95,6 +99,50 @@ describe("OMP session descriptor", () => {
});
});
+ test("resolves a bare session id to its file and reads the same import config", async () => {
+ const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-by-id-"));
+ const sessionDir = path.join(root, "sessions");
+ const cwd = path.join(root, "repo");
+ const sessionId = "01a03dc8-77bb-7000-b0a3-bb7d25477e81";
+ const sessionFile = await writeSession(
+ root,
+ `project/2026-08-26T11-15-43-163Z_${sessionId}.jsonl`,
+ [
+ { type: "title", v: 1, title: "", updatedAt: "2026-08-26T11:15:43.163Z" },
+ { type: "session", version: 3, id: sessionId, timestamp: "2026-08-26T11:15:43.163Z", cwd },
+ {
+ type: "model_change",
+ id: "model-1",
+ timestamp: "2026-08-26T11:15:43.200Z",
+ model: "anthropic/claude-opus-5",
+ },
+ ],
+ );
+ // A decoy whose filename does not follow the `_` convention
+ // but whose header carries the id must still be found.
+ const oddlyNamedId = "renamed-session";
+ const oddlyNamed = await writeSession(root, "project/notes.jsonl", [
+ { type: "session", id: oddlyNamedId, timestamp: "2026-08-27T00:00:00.000Z", cwd },
+ {
+ type: "model_change",
+ id: "model-2",
+ timestamp: "2026-08-27T00:00:00.100Z",
+ model: "mimorouter/claude-fable-5-1",
+ },
+ ]);
+
+ await expect(resolveOmpSessionFile(sessionId, { sessionDir })).resolves.toBe(sessionFile);
+ await expect(resolveOmpSessionFile(sessionFile, { sessionDir })).resolves.toBe(sessionFile);
+ await expect(resolveOmpSessionFile(oddlyNamedId, { sessionDir })).resolves.toBe(oddlyNamed);
+ await expect(resolveOmpSessionFile("does-not-exist", { sessionDir })).resolves.toBeNull();
+
+ const byId = await readOmpImportSessionConfig(sessionId, { sessionDir });
+ const byPath = await readOmpImportSessionConfig(sessionFile, { sessionDir });
+ expect(byId).toEqual({ model: "anthropic/claude-opus-5" });
+ expect(byId).toEqual(byPath);
+ await expect(readOmpImportSessionConfig("does-not-exist", { sessionDir })).resolves.toEqual({});
+ });
+
test("keeps recent nested OMP subagent sessions importable", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-nested-"));
const cwd = path.join(root, "repo");
@@ -145,4 +193,99 @@ describe("OMP session descriptor", () => {
expect.objectContaining({ providerHandleId: sessionFile, cwd }),
]);
});
+
+ test("uses a named OMP profile's session directory", async () => {
+ const home = await mkdtemp(path.join(tmpdir(), "paseo-omp-profile-home-"));
+ const cwd = path.join(home, "repo");
+ const sessionFile = path.join(
+ home,
+ ".omp",
+ "profiles",
+ "qa",
+ "agent",
+ "sessions",
+ "project",
+ "profile.jsonl",
+ );
+ await mkdir(path.dirname(sessionFile), { recursive: true });
+ await writeFile(sessionFile, JSON.stringify({ type: "session", id: "qa-profile", cwd }));
+
+ await expect(
+ listOmpImportableSessions({
+ homeDir: home,
+ env: {},
+ runtimeSettings: { env: { OMP_PROFILE: "qa" } },
+ }),
+ ).resolves.toEqual([expect.objectContaining({ providerHandleId: sessionFile })]);
+ });
+
+ test.skipIf(process.platform === "win32")(
+ "uses a profile's XDG data directory after OMP has created it",
+ async () => {
+ const home = await mkdtemp(path.join(tmpdir(), "paseo-omp-xdg-home-"));
+ const cwd = path.join(home, "repo");
+ const xdgDataHome = path.join(home, "xdg-data");
+ const sessionFile = path.join(
+ xdgDataHome,
+ "omp",
+ "profiles",
+ "qa",
+ "sessions",
+ "project",
+ "session.jsonl",
+ );
+ await mkdir(path.dirname(sessionFile), { recursive: true });
+ await writeFile(sessionFile, JSON.stringify({ type: "session", id: "xdg-profile", cwd }));
+
+ await expect(
+ listOmpImportableSessions({
+ homeDir: home,
+ env: { OMP_PROFILE: "qa", XDG_DATA_HOME: xdgDataHome },
+ }),
+ ).resolves.toEqual([expect.objectContaining({ providerHandleId: sessionFile })]);
+ },
+ );
+
+ test.runIf(process.platform === "win32")(
+ "ignores a profile's XDG data directory on Windows",
+ async () => {
+ const home = await mkdtemp(path.join(tmpdir(), "paseo-omp-windows-xdg-home-"));
+ const cwd = path.join(home, "repo");
+ const xdgDataHome = path.join(home, "xdg-data");
+ const xdgSessionFile = path.join(
+ xdgDataHome,
+ "omp",
+ "profiles",
+ "qa",
+ "sessions",
+ "project",
+ "xdg.jsonl",
+ );
+ const profileSessionFile = path.join(
+ home,
+ ".omp",
+ "profiles",
+ "qa",
+ "agent",
+ "sessions",
+ "project",
+ "profile.jsonl",
+ );
+ await Promise.all([
+ mkdir(path.dirname(xdgSessionFile), { recursive: true }),
+ mkdir(path.dirname(profileSessionFile), { recursive: true }),
+ ]);
+ await Promise.all([
+ writeFile(xdgSessionFile, JSON.stringify({ type: "session", id: "xdg-profile", cwd })),
+ writeFile(profileSessionFile, JSON.stringify({ type: "session", id: "qa-profile", cwd })),
+ ]);
+
+ await expect(
+ listOmpImportableSessions({
+ homeDir: home,
+ env: { OMP_PROFILE: "qa", XDG_DATA_HOME: xdgDataHome },
+ }),
+ ).resolves.toEqual([expect.objectContaining({ providerHandleId: profileSessionFile })]);
+ },
+ );
});
diff --git a/packages/server/src/server/agent/providers/omp/session-descriptor.ts b/packages/server/src/server/agent/providers/omp/session-descriptor.ts
index 5b471cb1ff..a97afe861d 100644
--- a/packages/server/src/server/agent/providers/omp/session-descriptor.ts
+++ b/packages/server/src/server/agent/providers/omp/session-descriptor.ts
@@ -2,6 +2,7 @@ import type { Dirent } from "node:fs";
import { open, readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
+import { existsSync } from "node:fs";
import type {
ImportableProviderSession,
@@ -11,8 +12,7 @@ import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
const OMP_CONFIG_DIR_NAME = ".omp";
-const OMP_AGENT_DIR_ENV = "OMP_AGENT_DIR";
-const OMP_SESSION_DIR_ENV = "OMP_SESSION_DIR";
+const OMP_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
// Import listing intentionally bounds header parsing to this window. Sessions
// with unusually large preambles may omit their first-prompt preview.
const HEAD_BYTES = 64 * 1024;
@@ -24,7 +24,7 @@ const FULL_SCAN_LINE_LIMIT = 2_000;
const IMPORT_CANDIDATE_OVERSCAN = 40;
const IMPORT_CANDIDATE_MIN = 400;
-interface OmpSessionDescriptorOptions extends ListImportableSessionsOptions {
+export interface OmpSessionDescriptorOptions extends ListImportableSessionsOptions {
sessionDir?: string;
runtimeSettings?: ProviderRuntimeSettings;
env?: NodeJS.ProcessEnv;
@@ -102,16 +102,70 @@ export async function listOmpImportableSessions(
);
}
+/**
+ * Reads the model/thinking config of an OMP session for import.
+ *
+ * `handle` is either an absolute path to the session `.jsonl` (what the
+ * importable-sessions list hands back) or a bare OMP session id (what
+ * `paseo import ` supplies). Ids are resolved against the configured
+ * sessions directory; an unresolvable handle yields `{}`.
+ */
export async function readOmpImportSessionConfig(
- filePath: string,
+ handle: string,
+ options: OmpSessionDescriptorOptions = {},
): Promise {
+ const filePath = await resolveOmpSessionFile(handle, options);
+ if (!filePath) return {};
const descriptor = await readOmpSessionDescriptor(filePath);
if (!descriptor) return {};
return toOmpImportSessionConfig(descriptor);
}
+/**
+ * Resolves an import handle to a session file path. Returns the handle itself
+ * when it already names a readable file; otherwise searches the sessions
+ * directory for a file whose header `id` matches.
+ */
+export async function resolveOmpSessionFile(
+ handle: string,
+ options: OmpSessionDescriptorOptions = {},
+): Promise {
+ const trimmed = handle.trim();
+ if (!trimmed) return null;
+ if (await isReadableFile(trimmed)) return trimmed;
+
+ const sessionsDir = await resolveOmpSessionsDir(options);
+ const files = await walkJsonlFiles(sessionsDir);
+ // OMP names session files `_.jsonl`; try those first so the
+ // common case opens a single file instead of scanning the directory.
+ const nameSuffix = `_${trimmed}.jsonl`;
+ const byName = files.filter((file) => path.basename(file).endsWith(nameSuffix));
+ for (const file of byName) {
+ if (await headerSessionIdMatches(file, trimmed)) return file;
+ }
+ const rest = byName.length > 0 ? files.filter((file) => !byName.includes(file)) : files;
+ for (const file of rest) {
+ if (await headerSessionIdMatches(file, trimmed)) return file;
+ }
+ return null;
+}
+
+async function isReadableFile(filePath: string): Promise {
+ try {
+ return (await stat(filePath)).isFile();
+ } catch {
+ return false;
+ }
+}
+
+async function headerSessionIdMatches(filePath: string, sessionId: string): Promise {
+ const headChunk = await readHeadChunk(filePath);
+ if (!headChunk) return false;
+ return parseSessionHeaderFromChunk(headChunk)?.sessionId === sessionId;
+}
+
async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Promise {
- const env = options.env ?? process.env;
+ const env = { ...(options.env ?? process.env), ...options.runtimeSettings?.env };
const homeDir = options.homeDir ?? homedir();
const baseDir = options.cwd ?? process.cwd();
@@ -119,10 +173,9 @@ async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Prom
return resolveConfigPath(options.sessionDir, { baseDir, homeDir });
}
- const agentDir = resolveOmpAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir });
+ const agentDir = resolveOmpAgentDir({ env, homeDir });
- const envSessionDir =
- options.runtimeSettings?.env?.[OMP_SESSION_DIR_ENV] ?? env[OMP_SESSION_DIR_ENV];
+ const envSessionDir = env[OMP_SESSION_DIR_ENV];
if (envSessionDir?.trim()) {
return resolveConfigPath(envSessionDir, { baseDir, homeDir });
}
@@ -135,20 +188,50 @@ async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Prom
return resolveConfigPath(settingsSessionDir, { baseDir, homeDir });
}
+ return resolveOmpDefaultSessionsDir(env, agentDir);
+}
+
+function resolveOmpDefaultSessionsDir(env: NodeJS.ProcessEnv, agentDir: string): string {
+ const profile = resolveOmpProfile(env);
+ const xdgDataHome = env.XDG_DATA_HOME;
+ if (
+ xdgDataHome &&
+ (process.platform === "linux" || process.platform === "darwin") &&
+ (profile || !env.PI_CODING_AGENT_DIR?.trim())
+ ) {
+ const xdgRoot = path.join(xdgDataHome, "omp", ...(profile ? ["profiles", profile] : []));
+ if (existsSync(xdgRoot)) return path.join(xdgRoot, "sessions");
+ }
return path.join(agentDir, "sessions");
}
-function resolveOmpAgentDir(input: {
- runtimeSettings?: ProviderRuntimeSettings;
- env: NodeJS.ProcessEnv;
- homeDir: string;
-}): string {
- const configured =
- input.runtimeSettings?.env?.[OMP_AGENT_DIR_ENV] ?? input.env[OMP_AGENT_DIR_ENV];
- if (configured?.trim()) {
- return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
+function resolveOmpAgentDir(input: { env: NodeJS.ProcessEnv; homeDir: string }): string {
+ const env = input.env;
+ const profile = resolveOmpProfile(env);
+ if (!profile) {
+ const configured = env.PI_CODING_AGENT_DIR;
+ if (configured?.trim()) {
+ return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
+ }
}
- return path.join(input.homeDir, OMP_CONFIG_DIR_NAME, "agent");
+ const configRoot = path.join(input.homeDir, env.PI_CONFIG_DIR || OMP_CONFIG_DIR_NAME);
+ const profileRoot = profile ? path.join(configRoot, "profiles", profile) : configRoot;
+ return path.join(profileRoot, "agent");
+}
+
+function resolveOmpProfile(env: NodeJS.ProcessEnv): string | undefined {
+ const profile = (env.OMP_PROFILE !== undefined ? env.OMP_PROFILE : env.PI_PROFILE)?.trim();
+ if (!profile || profile === "default") return undefined;
+ if (
+ profile === "." ||
+ profile === ".." ||
+ profile.endsWith(".") ||
+ !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(profile) ||
+ /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i.test(profile)
+ ) {
+ throw new Error(`Invalid OMP profile "${profile}"`);
+ }
+ return profile;
}
async function readConfiguredSessionDir(input: {
diff --git a/packages/server/src/server/agent/providers/omp/test-utils/echo-mcp-server.mjs b/packages/server/src/server/agent/providers/omp/test-utils/echo-mcp-server.mjs
new file mode 100644
index 0000000000..1821afb47d
--- /dev/null
+++ b/packages/server/src/server/agent/providers/omp/test-utils/echo-mcp-server.mjs
@@ -0,0 +1,47 @@
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { z } from "zod";
+import { writeFileSync } from "node:fs";
+
+if (process.env.OMP_MCP_PID_FILE) writeFileSync(process.env.OMP_MCP_PID_FILE, String(process.pid));
+
+const server = new McpServer({ name: "omp-echo-fixture", version: "1.0.0" });
+server.registerTool(
+ "echo_secret",
+ { description: "Returns the fixture word", inputSchema: { word: z.string() } },
+ async ({ word }, extra) => {
+ if (word === "ENV") {
+ return {
+ content: [
+ {
+ type: "text",
+ text: JSON.stringify({
+ runtime: process.env.OMP_MCP_RUNTIME_ENV,
+ agent: process.env.PASEO_AGENT_ID,
+ precedence: process.env.OMP_MCP_PRECEDENCE,
+ }),
+ },
+ ],
+ };
+ }
+ if (word === "WAIT") {
+ if (process.env.OMP_MCP_WAIT_FILE) writeFileSync(process.env.OMP_MCP_WAIT_FILE, "waiting");
+ return await new Promise((resolve) => {
+ extra.signal.addEventListener(
+ "abort",
+ () => {
+ if (process.env.OMP_MCP_CANCEL_FILE) {
+ writeFileSync(process.env.OMP_MCP_CANCEL_FILE, "cancelled");
+ }
+ resolve({ content: [{ type: "text", text: "cancelled" }] });
+ },
+ { once: true },
+ );
+ });
+ }
+ return word === "FAIL"
+ ? { content: [{ type: "text", text: "fixture failure" }], isError: true }
+ : { content: [{ type: "text", text: `MANGO:${word}` }] };
+ },
+);
+await server.connect(new StdioServerTransport());
diff --git a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts
index 68eed2fc3b..184a38bafa 100644
--- a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts
+++ b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts
@@ -54,6 +54,9 @@ export class FakeOmp implements OmpRuntime {
private readonly sessions: FakeOmpSession[] = [];
private readonly command: [string, ...string[]];
private readonly queuedCommands: OmpRpcSlashCommand[][] = [];
+ private nextStartError: Error | null = null;
+ private nextSessionId = 0;
+ private readonly sessionIdsByFile = new Map();
private readonly queuedSubagentSubscriptionErrors = new Map<
FakeOmpSubagentSubscriptionLevel,
Error
@@ -64,12 +67,19 @@ export class FakeOmp implements OmpRuntime {
}
async startSession(input: OmpStartSessionInput): Promise {
+ const startError = this.nextStartError;
+ this.nextStartError = null;
+ if (startError) throw startError;
const launch = buildOmpLaunch({
command: this.command,
session: input,
});
this.recordedLaunches.push(launch);
- const session = new FakeOmpSession(launch);
+ const sessionId =
+ (launch.session ? this.sessionIdsByFile.get(launch.session) : undefined) ??
+ `omp-session-${++this.nextSessionId}`;
+ const session = new FakeOmpSession(launch, sessionId);
+ if (!launch.noSession) this.sessionIdsByFile.set(session.state.sessionFile, sessionId);
session.commands = this.queuedCommands.shift() ?? [];
for (const [level, error] of this.queuedSubagentSubscriptionErrors) {
session.subagentSubscriptionErrors.set(level, error);
@@ -83,6 +93,10 @@ export class FakeOmp implements OmpRuntime {
this.queuedCommands.push(commands);
}
+ failNextStart(error: Error): void {
+ this.nextStartError = error;
+ }
+
failNextSubagentSubscription(level: FakeOmpSubagentSubscriptionLevel, error: Error): void {
this.queuedSubagentSubscriptionErrors.set(level, error);
}
@@ -94,9 +108,15 @@ export class FakeOmp implements OmpRuntime {
}
return session;
}
+
+ allSessions(): FakeOmpSession[] {
+ return [...this.sessions];
+ }
}
export class FakeOmpSession implements OmpRuntimeSession {
+ fastModeResult = { enabled: false, active: false };
+ readonly setFastModeRequests: boolean[] = [];
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = [];
@@ -106,6 +126,7 @@ export class FakeOmpSession implements OmpRuntimeSession {
readonly setThinkingLevelRequests: OmpThinkingLevel[] = [];
readonly handoffRequests: Array<{ customInstructions?: string }> = [];
readonly steerRequests: Array<{ message: string; imageCount: number }> = [];
+ steerError: Error | null = null;
readonly followUpRequests: Array<{ message: string; imageCount: number }> = [];
readonly hostToolSetRequests: OmpRpcHostToolDefinition[][] = [];
readonly hostToolResults: OmpRpcHostToolResult[] = [];
@@ -152,15 +173,17 @@ export class FakeOmpSession implements OmpRuntimeSession {
private activeHeldPrompt: { promise: Promise; reject: (error: Error) => void } | null =
null;
- constructor(launch: OmpRuntimeLaunch) {
+ constructor(launch: OmpRuntimeLaunch, sessionId = "omp-session-1") {
this.state = {
model: null,
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
autoCompactionEnabled: true,
+ fastModeEnabled: false,
+ fastModeActive: false,
sessionFile: launch.session ?? "/tmp/omp-session",
- sessionId: "omp-session-1",
+ sessionId,
messageCount: 0,
queuedMessageCount: 0,
};
@@ -269,6 +292,16 @@ export class FakeOmpSession implements OmpRuntimeSession {
return this.state;
}
+ async setFastMode(enabled: boolean): Promise<{ enabled: boolean; active: boolean }> {
+ this.setFastModeRequests.push(enabled);
+ this.state = {
+ ...this.state,
+ fastModeEnabled: this.fastModeResult.enabled,
+ fastModeActive: this.fastModeResult.active,
+ };
+ return this.fastModeResult;
+ }
+
/** Holds every state request until the returned function releases them. */
holdStateRequests(): () => void {
const held: Array<() => void> = [];
@@ -350,8 +383,12 @@ export class FakeOmpSession implements OmpRuntimeSession {
return this.branchMessages;
}
- steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void {
+ async steer(
+ message: string,
+ images?: Array<{ type: "image"; data: string; mimeType: string }>,
+ ): Promise {
this.steerRequests.push({ message, imageCount: images?.length ?? 0 });
+ if (this.steerError) throw this.steerError;
}
followUp(
diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts
index 7c99fa772c..5c53dd3e4f 100644
--- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts
+++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts
@@ -71,14 +71,18 @@ export class OmpHarness {
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
+ providerIdleDeadlineMs?: number;
+ runtimeEnv?: Record;
} = {},
) {
this.client = new OmpAgentClient({
logger: pino({ level: "silent" }),
+ runtimeSettings: { env: options.runtimeEnv },
runtime: this.omp,
providerIdleScheduler: options.providerIdleScheduler,
noTurnScheduler: options.noTurnScheduler,
usagePollScheduler: options.usagePollScheduler,
+ providerIdleDeadlineMs: options.providerIdleDeadlineMs,
});
}
@@ -151,6 +155,20 @@ export class OmpHarness {
return this.client.capabilities;
}
+ persistence() {
+ return this.requireSession().describePersistence();
+ }
+
+ async replayHistory(handle: AgentPersistenceHandle): Promise {
+ const session = await this.client.resumeSession(handle, undefined, undefined, {
+ purpose: "history",
+ });
+ const events: AgentStreamEvent[] = [];
+ for await (const event of session.streamHistory()) events.push(event);
+ await session.close();
+ return events;
+ }
+
async runPrompt(
input: string,
output: string,
@@ -451,6 +469,36 @@ export class OmpHarness {
return await this.requireSession().setMode(modeId);
}
+ currentMode() {
+ return this.requireSession().getCurrentMode();
+ }
+
+ runtimeLaunches() {
+ return this.omp.recordedLaunches;
+ }
+
+ runtimeSessions() {
+ return this.omp.allSessions();
+ }
+
+ processExit(error: string): void {
+ this.omp.latestSession().emit({ type: "process_exit", error });
+ }
+
+ failNextStart(error: Error): void {
+ this.omp.failNextStart(error);
+ }
+
+ turnFailures(): string[] {
+ return this.events.flatMap((event) => (event.type === "turn_failed" ? [event.error] : []));
+ }
+
+ threadStartedSessionIds(): string[] {
+ return this.events.flatMap((event) =>
+ event.type === "thread_started" ? [event.sessionId] : [],
+ );
+ }
+
async rewind(messageId: string, restoredPrompt: string): Promise {
this.omp.latestSession().branchResponse = { text: restoredPrompt };
await this.requireSession().revertConversation({ messageId });
@@ -471,6 +519,15 @@ export class OmpHarness {
await promptStarted;
}
+ async startTurn(message: string): Promise {
+ await this.requireSession().startTurn(message);
+ await waitForImmediate();
+ }
+
+ startTurnDetached(message: string) {
+ return this.requireSession().startTurn(message);
+ }
+
async interrupt(): Promise {
await this.requireSession().interrupt();
}
diff --git a/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts b/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts
index e04ddee2bc..85e4b9a3a9 100644
--- a/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts
+++ b/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts
@@ -103,4 +103,41 @@ describe("OMP todo mapper", () => {
mapOmpTodoToolResult({ details: { phases: [{ name: "Bad", tasks: [{}] }] } }),
).toBeNull();
});
+
+ test("keeps blocked work visible and excludes abandoned work from active todos", () => {
+ const phases = [
+ {
+ name: "Tasks",
+ tasks: [
+ { content: "Wait for access", status: "blocked", blocker: "approval" },
+ { content: "Old approach", status: "abandoned" },
+ ],
+ },
+ ];
+ const expected = {
+ type: "todo",
+ items: [{ text: "Wait for access (blocked: approval)", status: "pending", completed: false }],
+ };
+ expect(mapOmpTodoToolResult(parseToolResult({ content: [], details: { phases } }))).toEqual(
+ expected,
+ );
+ expect(
+ mapOmpTodoState({
+ isStreaming: false,
+ isCompacting: false,
+ sessionId: "s",
+ todoPhases: phases,
+ }),
+ ).toEqual([expected]);
+ expect(
+ mapOmpTodoToolResult(
+ parseToolResult({
+ content: [],
+ details: {
+ phases: [{ name: "Tasks", tasks: [{ content: "Old approach", status: "abandoned" }] }],
+ },
+ }),
+ ),
+ ).toEqual({ type: "todo", items: [] });
+ });
});
diff --git a/packages/server/src/server/agent/providers/omp/todo-mapper.ts b/packages/server/src/server/agent/providers/omp/todo-mapper.ts
index 0b41d5a43f..699a40d3aa 100644
--- a/packages/server/src/server/agent/providers/omp/todo-mapper.ts
+++ b/packages/server/src/server/agent/providers/omp/todo-mapper.ts
@@ -39,11 +39,16 @@ function mapOmpTodoItems(items: readonly OmpTodoItem[]): AgentTimelineItem | nul
}
return {
type: "todo",
- items: items.map((item) => ({
- text: item.content,
- status: normalizeOmpTodoStatus(item.status),
- completed: item.status === "completed",
- })),
+ items: items
+ .filter((item) => item.status !== "abandoned")
+ .map((item) => ({
+ text:
+ item.status === "blocked"
+ ? `${item.content} (${item.blocker ? `blocked: ${item.blocker}` : "blocked"})`
+ : item.content,
+ status: normalizeOmpTodoStatus(item.status),
+ completed: item.status === "completed",
+ })),
};
}
diff --git a/packages/server/src/server/agent/providers/omp/tool-call-detail.ts b/packages/server/src/server/agent/providers/omp/tool-call-detail.ts
index 7df6efb676..f0e9d15532 100644
--- a/packages/server/src/server/agent/providers/omp/tool-call-detail.ts
+++ b/packages/server/src/server/agent/providers/omp/tool-call-detail.ts
@@ -59,6 +59,7 @@ interface OmpToolResultObject {
interface OmpToolResultDetails {
diff?: string;
+ error?: string;
mode?: string;
xdev?: unknown;
}
@@ -270,6 +271,39 @@ export function extractTextFromToolResult(result: OmpToolResult): string | undef
return textParts.length > 0 ? textParts.join("\n") : undefined;
}
+export function toolFailureMessage(result: OmpToolResult): string {
+ if (result && typeof result !== "string" && result.details?.error) {
+ return result.details.error.split("\n", 1)[0].slice(0, 240);
+ }
+ const output = extractTextFromToolResult(result);
+ const exitMessage = output?.match(/(?:Command|Process) exited with code \d+/i)?.[0];
+ if (exitMessage) return exitMessage;
+ const firstLine = output
+ ?.split("\n")
+ .find((line) => line.trim())
+ ?.trim();
+ if (firstLine) return firstLine.slice(0, 240);
+ if (result && typeof result !== "string") {
+ const code = result.exitCode ?? result.code;
+ if (typeof code === "number") return `Tool exited with code ${code}`;
+ }
+ return "Tool call failed";
+}
+
+export function isOmpToolFailure(
+ toolCall: OmpTrackedToolCall,
+ result: OmpToolResult,
+ isError: boolean,
+): boolean {
+ return (
+ isError ||
+ (toolCall.toolName === "web_search" &&
+ result !== null &&
+ typeof result !== "string" &&
+ typeof result.details?.error === "string")
+ );
+}
+
export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToolCall {
if (toolName === "edit") {
return parseEditToolArgs(rawArgs);
@@ -289,6 +323,12 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToo
}
export function resolveToolCallName(toolCall: OmpTrackedToolCall, result?: OmpToolResult): string {
+ if (
+ (toolCall.kind === "read" || toolCall.kind === "write") &&
+ toolCall.args.path.startsWith("xd://")
+ ) {
+ return toolCall.args.path.slice("xd://".length).split(/[/?#]/, 1)[0] || toolCall.toolName;
+ }
if (toolCall.kind === "write" && result && typeof result !== "string") {
const xdev = XdevExecuteDetailsSchema.safeParse(result.details?.xdev);
if (xdev.success) {
@@ -316,6 +356,20 @@ export function mapToolDetail(
};
}
case "read":
+ if (toolCall.args.path.startsWith("xd://")) {
+ return {
+ type: "plain_text",
+ label: resolveToolCallName(toolCall),
+ text: extractTextFromToolResult(parsedResult),
+ };
+ }
+ if (/^https?:\/\//.test(toolCall.args.path)) {
+ return {
+ type: "fetch",
+ url: toolCall.args.path,
+ result: extractTextFromToolResult(parsedResult),
+ };
+ }
return {
type: "read",
filePath: toolCall.args.path,
@@ -374,6 +428,15 @@ function mapWriteToolDetail(args: WriteToolInput, result: OmpToolResult): ToolCa
};
}
+ // A write to `xd://` runs a tool, not a file write. Name the invoked tool while it runs.
+ if (args.path.startsWith("xd://")) {
+ return {
+ type: "plain_text",
+ label: args.path.slice("xd://".length).split(/[/?#]/, 1)[0],
+ text: extractTextFromToolResult(result),
+ };
+ }
+
return {
type: "write",
filePath: args.path,
diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts
index dcccea3b60..ae4c0d0d77 100644
--- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts
+++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts
@@ -117,4 +117,218 @@ describe("OMP tool call mapper", () => {
output: null,
});
});
+
+ test("uses current OMP task arguments and wait results for readable rows", () => {
+ expect(
+ mapOmpToolDetail(
+ parseToolArgs("task", {
+ name: "Docs",
+ agent: "explore",
+ task: "Read src/a.ts",
+ solutionSpace: "Report findings",
+ }),
+ null,
+ ),
+ ).toMatchObject({ type: "sub_agent", subAgentType: "Docs", description: "Read src/a.ts" });
+ expect(
+ mapOmpToolDetail(
+ parseToolArgs("wait", {}),
+ parseToolResult({ content: [{ type: "text", text: "Waiting for Docs to finish" }] }),
+ ),
+ ).toMatchObject({ type: "plain_text", label: "Waiting for Docs to finish" });
+ });
+
+ test("names a task from OMP's tasks array while it runs and after it completes", () => {
+ const call = parseToolArgs("task", {
+ context: "Read a scratch file",
+ tasks: [{ name: "ReadNotes", task: "Read src/notes.ts and report its content" }],
+ i: "Delegating the read",
+ });
+ expect(mapOmpToolDetail(call, null)).toMatchObject({
+ type: "sub_agent",
+ subAgentType: "ReadNotes",
+ description: "Delegating the read",
+ });
+ expect(
+ mapOmpToolDetail(
+ call,
+ parseToolResult({ content: [{ type: "text", text: "Spawned agent `ReadNotes`" }] }),
+ ),
+ ).toMatchObject({ type: "sub_agent", subAgentType: "ReadNotes" });
+ });
+
+ test("uses the first task instruction when OMP omits its short intent", () => {
+ expect(
+ mapOmpToolDetail(
+ parseToolArgs("task", {
+ tasks: [
+ {
+ name: "WaitThenRead",
+ task: "# Target src/notes.ts in the scratch repository.\n# Change\nRun bash sleep 8.",
+ },
+ ],
+ }),
+ null,
+ ),
+ ).toMatchObject({
+ type: "sub_agent",
+ subAgentType: "WaitThenRead",
+ description: "src/notes.ts in the scratch repository.",
+ });
+ });
+
+ test("skips a standalone target heading in a running task", () => {
+ expect(
+ mapOmpToolDetail(
+ parseToolArgs("task", {
+ tasks: [
+ {
+ name: "SlowRead",
+ task: "# Target\nsrc/notes.ts in the scratch repository.\n# Change\nRead the file.",
+ },
+ ],
+ }),
+ null,
+ ),
+ ).toMatchObject({ description: "src/notes.ts in the scratch repository." });
+ });
+
+ test("names a structured subagent yield by its action", () => {
+ expect(
+ mapOmpToolDetail(
+ parseToolArgs("yield", { data: { content: "done" } }),
+ parseToolResult({ content: [{ type: "text", text: "Result submitted." }] }),
+ ),
+ ).toEqual({
+ type: "plain_text",
+ label: "Submitted subagent result",
+ text: "Result submitted.",
+ });
+ });
+
+ test("connects a steered background bash call with its wait result", () => {
+ const command = "sleep 2; echo finished";
+ expect(
+ mapOmpToolDetail(
+ parseToolArgs("bash", { command }),
+ parseToolResult({
+ content: [
+ {
+ type: "text",
+ text: "Backgrounded early to handle an incoming message; the command keeps running.\nJob: bg_1",
+ },
+ ],
+ }),
+ ),
+ ).toMatchObject({ type: "shell", command });
+ const resultText =
+ "## Completed (1)\n\n### bg_1 [bash] — completed\nLabel: sleep 2; echo finished\nDelivery: not auto-delivered; recovered by this snapshot.\n```\nfinished\n```";
+ expect(
+ mapOmpToolDetail(
+ parseToolArgs("wait", {}),
+ parseToolResult({
+ content: [{ type: "text", text: resultText }],
+ details: {
+ op: "wait",
+ jobs: [
+ {
+ id: "bg_1",
+ type: "bash",
+ status: "completed",
+ label: command,
+ resultText: "finished",
+ },
+ ],
+ },
+ }),
+ ),
+ ).toEqual({ type: "plain_text", label: `bash completed: ${command}`, text: "finished" });
+ });
+
+ test("maps web search and URL reads to search and fetch", () => {
+ expect(mapOmpToolDetail(parseToolArgs("web_search", { query: "Paseo" }), null)).toMatchObject({
+ type: "search",
+ query: "Paseo",
+ toolName: "web_search",
+ });
+ expect(
+ mapOmpToolDetail(parseToolArgs("read", { path: "https://example.com/" }), null),
+ ).toMatchObject({ type: "fetch", url: "https://example.com/" });
+ });
+
+ test("names virtual device calls from their path on failure and while running", () => {
+ expect(mapOmpToolDetail(parseToolArgs("read", { path: "xd://ast_grep" }), null)).toMatchObject({
+ type: "plain_text",
+ label: "ast_grep",
+ });
+ expect(
+ mapOmpToolDetail(parseToolArgs("write", { path: "xd://lsp", content: "{}" }), null),
+ ).toMatchObject({ type: "plain_text", label: "lsp" });
+ });
+
+ test("renders executed OMP device tools with readable action and output", () => {
+ expect(
+ mapOmpToolDetail(
+ parseToolArgs("write", { path: "xd://github", content: '{"op":"status"}' }),
+ parseToolResult({
+ content: [{ type: "text", text: "Working tree clean" }],
+ details: {
+ xdev: { tool: "github", mode: "execute", args: { op: "status" }, inner: {} },
+ },
+ }),
+ ),
+ ).toEqual({ type: "plain_text", label: "status", text: "Working tree clean" });
+ });
+
+ test("shows the first question in an ask row", () => {
+ expect(
+ mapOmpToolDetail(parseToolArgs("ask", { questions: [{ question: "Which file?" }] }), null),
+ ).toMatchObject({ type: "plain_text", label: "Which file?" });
+ });
+
+ test("shows the question and selected answers after ask completes", () => {
+ const detail = mapOmpToolDetail(
+ parseToolArgs("ask", { questions: [{ question: "Which colors?" }] }),
+ parseToolResult({
+ content: [{ type: "text", text: "User selected: Red, Blue" }],
+ details: { question: "Which colors?", selectedOptions: ["Red", "Blue"] },
+ }),
+ );
+ expect(detail).toEqual({
+ type: "plain_text",
+ label: "Which colors?",
+ text: "Which colors?\nRed, Blue",
+ });
+ });
+
+ test("shows meaningful labels for OMP's remaining built-in tools", () => {
+ expect(mapOmpToolDetail(parseToolArgs("glob", { pattern: "src/**/*.ts" }), null)).toMatchObject(
+ { type: "search", query: "src/**/*.ts", toolName: "glob" },
+ );
+ expect(
+ mapOmpToolDetail(parseToolArgs("ast_grep", { pattern: "console.log($X)" }), null),
+ ).toMatchObject({ type: "search", query: "console.log($X)" });
+ expect(
+ mapOmpToolDetail(parseToolArgs("checkpoint", { goal: "Inspect parser" }), null),
+ ).toMatchObject({ type: "plain_text", label: "Inspect parser" });
+ expect(
+ mapOmpToolDetail(parseToolArgs("manage_skill", { action: "create", name: "demo" }), null),
+ ).toMatchObject({ type: "plain_text", label: "create demo" });
+ expect(
+ mapOmpToolDetail(parseToolArgs("think", { thoughts: "Check the next step" }), null),
+ ).toEqual({
+ type: "plain_text",
+ label: "Thinking",
+ text: "Check the next step",
+ });
+ });
+
+ test("uses OMP ast_grep pat as the search query", () => {
+ expect(
+ mapOmpToolDetail(parseToolArgs("ast_grep", { pat: "console.log($A)" }), null),
+ ).toMatchObject({
+ type: "search",
+ query: "console.log($A)",
+ });
+ });
});
diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts
index 180790ab3a..86b1ee53ef 100644
--- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts
+++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts
@@ -1,7 +1,9 @@
import type { ToolCallDetail } from "../../agent-sdk-types.js";
+import type { OmpBridgedToolIdentity } from "./mcp-bridge.js";
import {
extractTextFromToolResult,
mapToolDetail as mapOmpCoreToolDetail,
+ resolveToolCallName,
type OmpToolResult,
type OmpTrackedToolCall,
} from "./tool-call-detail.js";
@@ -11,9 +13,13 @@ export function mapOmpToolDetail(
result: OmpToolResult,
context?: {
toolCallId: string;
+ bridgedTool?: OmpBridgedToolIdentity;
mapSubagentDetail?: (baseDetail: ToolCallDetail) => ToolCallDetail;
},
): ToolCallDetail | null {
+ if (context?.bridgedTool) {
+ return { type: "unknown", input: toolCall.args, output: result };
+ }
if (toolCall.toolName === "todo") {
return null;
}
@@ -21,6 +27,10 @@ export function mapOmpToolDetail(
const detail = mapOmpTaskDetail(toolCall.args, result);
return context?.mapSubagentDetail?.(detail) ?? detail;
}
+ const deviceDetail = mapOmpDeviceToolDetail(toolCall, result);
+ if (deviceDetail) return deviceDetail;
+ const extended = mapOmpExtendedToolDetail(toolCall, result);
+ if (extended) return extended;
if (toolCall.toolName === "edit") {
return mapOmpEditDetail(toolCall, result);
}
@@ -30,19 +40,160 @@ export function mapOmpToolDetail(
return mapOmpCoreToolDetail(toolCall, result);
}
+function mapOmpDeviceToolDetail(
+ toolCall: OmpTrackedToolCall,
+ result: OmpToolResult,
+): ToolCallDetail | null {
+ if (toolCall.kind !== "write" || !toolCall.args.path.startsWith("xd://")) return null;
+ const toolName = resolveToolCallName(toolCall, result);
+ if (!OPAQUE_TEXT_TOOLS.has(toolName) && toolName !== "ast_grep") return null;
+ let args: unknown;
+ try {
+ args = JSON.parse(toolCall.args.content);
+ } catch {
+ args = null;
+ }
+ return mapOmpExtendedToolDetail({ kind: "unknown", toolName, args }, result);
+}
+
+function mapOmpExtendedToolDetail(
+ toolCall: OmpTrackedToolCall,
+ result: OmpToolResult,
+): ToolCallDetail | null {
+ if (toolCall.toolName === "yield") {
+ return {
+ type: "plain_text",
+ label: "Submitted subagent result",
+ text: extractTextFromToolResult(result),
+ };
+ }
+ if (toolCall.toolName === "wait") {
+ return mapOmpWaitDetail(result);
+ }
+ if (toolCall.toolName === "web_search") {
+ const args = isRecord(toolCall.args) ? toolCall.args : {};
+ return {
+ type: "search",
+ query: firstString(args.query) ?? "Web search",
+ toolName: "web_search",
+ content: extractTextFromToolResult(result),
+ };
+ }
+ if (toolCall.toolName === "ask" || toolCall.toolName === "ask_user") {
+ return mapOmpAskDetail(toolCall.args, result);
+ }
+ if (toolCall.toolName === "glob" || toolCall.toolName === "ast_grep") {
+ const args = isRecord(toolCall.args) ? toolCall.args : {};
+ return {
+ type: "search",
+ query: firstString(args.pattern, args.pat, args.query) ?? toolCall.toolName,
+ ...(toolCall.toolName === "glob" ? { toolName: "glob" as const } : {}),
+ content: extractTextFromToolResult(result),
+ };
+ }
+ if (toolCall.toolName === "think") {
+ const args = isRecord(toolCall.args) ? toolCall.args : {};
+ return { type: "plain_text", label: "Thinking", text: firstString(args.thoughts) };
+ }
+ return mapOmpOpaqueToolDetail(toolCall, result);
+}
+
+function mapOmpWaitDetail(result: OmpToolResult): ToolCallDetail {
+ const text = extractTextFromToolResult(result)?.trim();
+ const jobs = resultDetails(result)?.jobs;
+ const firstJob = Array.isArray(jobs) ? jobs.find(isRecord) : undefined;
+ const jobType = firstString(firstJob?.type);
+ const jobStatus = firstString(firstJob?.status);
+ const jobLabel = firstString(firstJob?.label, firstJob?.id);
+ const label =
+ jobType && jobStatus && jobLabel
+ ? `${jobType} ${jobStatus}: ${jobLabel}`
+ : (text?.split("\n", 1)[0] ?? "Waiting for background work");
+ return {
+ type: "plain_text",
+ label,
+ text: firstString(firstJob?.resultText, firstJob?.errorText) ?? text,
+ };
+}
+
+function mapOmpAskDetail(rawArgs: unknown, result: OmpToolResult): ToolCallDetail {
+ const args = isRecord(rawArgs) ? rawArgs : {};
+ const firstQuestion = Array.isArray(args.questions) ? args.questions[0] : undefined;
+ const askedQuestion = isRecord(firstQuestion) ? firstString(firstQuestion.question) : undefined;
+ const details =
+ result && typeof result !== "string" && isRecord(result.details) ? result.details : {};
+ const answers = Array.isArray(details.results) ? details.results : [details];
+ const lines = answers.flatMap((answer) => {
+ if (!isRecord(answer)) return [];
+ const question = firstString(answer.question);
+ const selected = Array.isArray(answer.selectedOptions)
+ ? answer.selectedOptions.filter((option): option is string => typeof option === "string")
+ : [];
+ const response =
+ [...selected, firstString(answer.customInput)].filter(Boolean).join(", ") || "No selection";
+ return question ? [`${question}\n${response}`] : [];
+ });
+ const text = lines.length ? lines.join("\n\n") : extractTextFromToolResult(result);
+ return {
+ type: "plain_text",
+ label: firstString(details.question, askedQuestion, args.question, args.title) ?? "Question",
+ text,
+ };
+}
+
+function mapOmpOpaqueToolDetail(
+ toolCall: OmpTrackedToolCall,
+ result: OmpToolResult,
+): ToolCallDetail | null {
+ if (OPAQUE_TEXT_TOOLS.has(toolCall.toolName)) {
+ const args = isRecord(toolCall.args) ? toolCall.args : {};
+ const action = firstString(args.action, args.op, args.operation, args.command);
+ const target = firstString(args.name, args.goal, args.path, args.query, args.id, args.report);
+ const label =
+ [action, target].filter(Boolean).join(" ") || toolCall.toolName.replaceAll("_", " ");
+ return { type: "plain_text", label, text: extractTextFromToolResult(result) };
+ }
+ return null;
+}
+
+const OPAQUE_TEXT_TOOLS = new Set([
+ "ast_edit",
+ "debug",
+ "eval",
+ "github",
+ "checkpoint",
+ "rewind",
+ "context_notes",
+ "new_context",
+ "security_scan",
+ "memory_edit",
+ "retain",
+ "recall",
+ "reflect",
+ "learn",
+ "manage_skill",
+ "yield",
+ "goal",
+]);
+
function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail {
const argRecord = isRecord(args) ? args : {};
+ const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined;
const resultText = extractTextFromToolResult(result);
const childSessionId = readChildSessionId(result);
return {
type: "sub_agent",
subAgentType: firstString(
+ firstTask?.name,
+ argRecord.name,
argRecord.agent,
argRecord.subAgentType,
argRecord.agentType,
argRecord.type,
),
description: firstString(
+ argRecord.i,
+ taskInstructionSummary(firstTask?.task),
argRecord.description,
argRecord.task,
argRecord.prompt,
@@ -53,6 +204,15 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail
};
}
+function taskInstructionSummary(value: unknown): string | undefined {
+ const instruction = firstString(value);
+ if (!instruction) return undefined;
+ return instruction
+ .split("\n")
+ .map((line) => line.replace(/^#+\s*(?:Target|Goal|Task)\s*/i, "").trim())
+ .find(Boolean);
+}
+
function mapOmpEditDetail(
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts
index 7a0776b52c..e2dc4f68a6 100644
--- a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts
+++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts
@@ -3,6 +3,7 @@ import type {
SessionInfo,
SessionMessageInfo,
SessionCreateInput,
+ SessionLogItem,
} from "@opencode/client";
import type { V2Api } from "../v2/api.js";
import type { V2Connection } from "../v2/runtime.js";
@@ -28,17 +29,61 @@ export class V2Harness {
readonly environments: Array<{ sessionID: string; variables: Record }> = [];
readonly mcpAdds: string[] = [];
releases = 0;
+ active = false;
+ activeReads = 0;
+ autoComplete = true;
+ constructor(private sequence = 0) {}
+ private executions: SessionLogItem[] = [];
+ startExecution() {
+ this.active = true;
+ this.push({
+ id: `execution-${++this.sequence}`,
+ created: this.sequence,
+ type: "session.execution.started",
+ durable: { aggregateID: this.info.id, seq: this.sequence, version: 1 },
+ data: { sessionID: this.info.id },
+ });
+ }
+ finishExecution(outcome: "succeeded" | "interrupted" = "succeeded", publish = true) {
+ this.active = false;
+ this.info.outcome = outcome;
+ const common = {
+ id: `execution-${++this.sequence}`,
+ created: this.sequence,
+ durable: { aggregateID: this.info.id, seq: this.sequence, version: 1 as const },
+ };
+ this.push(
+ outcome === "succeeded"
+ ? { ...common, type: "session.execution.succeeded", data: { sessionID: this.info.id } }
+ : {
+ ...common,
+ type: "session.execution.interrupted",
+ data: { sessionID: this.info.id, reason: "user" },
+ },
+ publish,
+ );
+ }
prompt: V2Api["session"]["prompt"] = async (input) => {
this.prompts.push(input.text);
+ if (!this.autoComplete) this.startExecution();
};
- wait: V2Api["session"]["wait"] = async () => undefined;
interrupt: V2Api["session"]["interrupt"] = async () => {
- this.info.outcome = "interrupted";
+ this.finishExecution("interrupted");
return { interrupted: true };
};
private pendingEvents: OpenCodeEvent[] = [];
private notify: (() => void) | null = null;
- push(event: OpenCodeEvent) {
+ push(event: OpenCodeEvent, publish = true) {
+ if (
+ event.type === "session.execution.started" ||
+ event.type === "session.execution.succeeded" ||
+ event.type === "session.execution.failed" ||
+ event.type === "session.execution.interrupted"
+ ) {
+ this.sequence = Math.max(this.sequence, event.durable.seq);
+ this.executions.push(event);
+ }
+ if (!publish) return;
this.pendingEvents.push(event);
this.notify?.();
}
@@ -93,7 +138,10 @@ export class V2Harness {
},
get: async () => this.info,
list: async () => ({ data: [], cursor: {} }),
- active: async () => ({}),
+ active: async () => {
+ this.activeReads += 1;
+ return this.active ? { [this.info.id]: { type: "running" as const } } : {};
+ },
remove: async () => undefined,
switchAgent: unexpected,
switchModel: unexpected,
@@ -101,13 +149,21 @@ export class V2Harness {
this.environments.push(input);
},
instructions: { entry: { list: unexpected, put: unexpected, remove: unexpected } },
- prompt: (input, options) => this.prompt(input, options),
- wait: (input, options) => this.wait(input, options),
+ prompt: async (input, options) => {
+ const before = this.executions.length;
+ await this.prompt(input, options);
+ if (
+ this.autoComplete &&
+ this.executions.length === before &&
+ this.info.outcome !== "failed"
+ )
+ this.finishExecution();
+ },
interrupt: (input, options) => this.interrupt(input, options),
command: unexpected,
compact: unexpected,
revert: { stage: unexpected, clear: unexpected, commit: unexpected },
- log: unexpected,
+ log: (input) => this.executionLog(input.after),
},
event: { subscribe: (options) => this.events(options?.signal) },
};
@@ -121,6 +177,12 @@ export class V2Harness {
};
readonly runtime = { acquire: async () => this.connection, shutdown: async () => undefined };
+ private async *executionLog(after = 0) {
+ for (const event of this.executions) {
+ if ("durable" in event && event.durable.seq > after) yield event;
+ }
+ }
+
private async *events(signal?: AbortSignal): AsyncGenerator {
yield { id: "connected", created: 1, type: "server.connected", data: {} };
const wake = () => this.notify?.();
diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts
index 29223073bb..8c91854fb8 100644
--- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts
+++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts
@@ -6,7 +6,289 @@ import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
+function terminalEvents(events: AgentStreamEvent[]) {
+ return events.filter(
+ (event) =>
+ event.type === "turn_completed" ||
+ event.type === "turn_failed" ||
+ event.type === "turn_canceled",
+ );
+}
+
describe("OpenCode v2 session lifecycle", () => {
+ test("completes from execution events without a long-lived HTTP wait", async () => {
+ const harness = new V2Harness();
+ harness.prompt = async (input) => {
+ harness.prompts.push(input.text);
+ harness.startExecution();
+ };
+ const client = new OpenCodeV2AgentClient({
+ logger: createTestLogger(),
+ runtime: harness.runtime,
+ });
+ const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
+ const events: AgentStreamEvent[] = [];
+ session.subscribe((event) => events.push(event));
+ try {
+ await session.startTurn("long-running work");
+ await expect.poll(() => harness.prompts).toEqual(["long-running work"]);
+ expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
+ expect(events.filter((event) => event.type === "turn_completed")).toEqual([]);
+ harness.finishExecution();
+ await expect.poll(() => terminalEvents(events)).toHaveLength(1);
+ expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
+ } finally {
+ await session.close();
+ }
+ });
+ test.each(["reconnect", "watchdog"])(
+ "recovers a missed completion through %s without reconnecting a quiet stream",
+ async (recovery) => {
+ const harness = new V2Harness();
+ harness.autoComplete = false;
+ let subscriptions = 0;
+ const subscribe = harness.api.event.subscribe;
+ harness.api.event.subscribe = (options) => {
+ subscriptions += 1;
+ return subscribe(options);
+ };
+ const client = new OpenCodeV2AgentClient({
+ logger: createTestLogger(),
+ runtime: harness.runtime,
+ });
+ const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
+ const events: AgentStreamEvent[] = [];
+ session.subscribe((event) => events.push(event));
+ try {
+ await session.startTurn("finish while disconnected");
+ await expect.poll(() => harness.prompts).toHaveLength(1);
+ // Persisted completion without its event, as after an upstream stream gap.
+ harness.finishExecution("succeeded", false);
+ if (recovery === "reconnect")
+ harness.push({ id: "reconnect", created: 3, type: "server.connected", data: {} });
+ await expect.poll(() => terminalEvents(events), { timeout: 7_000 }).toHaveLength(1);
+ expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" });
+ expect(subscriptions).toBe(1);
+ } finally {
+ await session.close();
+ }
+ },
+ 10_000,
+ );
+
+ test("does not finish from a previous outcome before prompt admission", async () => {
+ const harness = new V2Harness();
+ let accept!: () => void;
+ harness.prompt = () =>
+ new Promise((resolve) => {
+ accept = resolve;
+ });
+ const client = new OpenCodeV2AgentClient({
+ logger: createTestLogger(),
+ runtime: harness.runtime,
+ });
+ const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
+ const events: AgentStreamEvent[] = [];
+ session.subscribe((event) => events.push(event));
+ try {
+ await session.startTurn("fast execution");
+ const reads = harness.activeReads;
+ harness.push({ id: "reconnect", created: 3, type: "server.connected", data: {} });
+ await expect.poll(() => harness.activeReads).toBeGreaterThan(reads);
+ expect(terminalEvents(events)).toEqual([]);
+ harness.startExecution();
+ harness.finishExecution();
+ accept();
+ await expect.poll(() => terminalEvents(events)).toHaveLength(1);
+ expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" });
+ } finally {
+ await session.close();
+ }
+ });
+
+ test("follows a queued successor until the session is idle", async () => {
+ const harness = new V2Harness();
+ harness.autoComplete = false;
+ const client = new OpenCodeV2AgentClient({
+ logger: createTestLogger(),
+ runtime: harness.runtime,
+ });
+ const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
+ const events: AgentStreamEvent[] = [];
+ session.subscribe((event) => events.push(event));
+ try {
+ await session.startTurn("first and queued work");
+ await expect.poll(() => harness.prompts).toHaveLength(1);
+ harness.finishExecution();
+ harness.startExecution();
+ const reads = harness.activeReads;
+ harness.push({ id: "reconnect", created: 3, type: "server.connected", data: {} });
+ await expect.poll(() => harness.activeReads).toBeGreaterThan(reads);
+ expect(terminalEvents(events)).toEqual([]);
+ harness.finishExecution();
+ await expect.poll(() => terminalEvents(events)).toHaveLength(1);
+ } finally {
+ await session.close();
+ }
+ });
+
+ test("discards an idle snapshot overtaken by a new execution event", async () => {
+ const harness = new V2Harness();
+ harness.autoComplete = false;
+ const client = new OpenCodeV2AgentClient({
+ logger: createTestLogger(),
+ runtime: harness.runtime,
+ });
+ const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
+ const events: AgentStreamEvent[] = [];
+ session.subscribe((event) => events.push(event));
+ let readSnapshot!: () => void;
+ let release!: () => void;
+ const reading = new Promise((resolve) => {
+ readSnapshot = resolve;
+ });
+ const blocked = new Promise((resolve) => {
+ release = resolve;
+ });
+ try {
+ await session.startTurn("work");
+ await expect.poll(() => harness.prompts).toHaveLength(1);
+ harness.api.session.get = async () => {
+ const snapshot = { ...harness.info };
+ readSnapshot();
+ await blocked;
+ return snapshot;
+ };
+ harness.finishExecution();
+ await reading;
+ const reads = harness.activeReads;
+ harness.startExecution();
+ release();
+ await expect.poll(() => harness.activeReads).toBeGreaterThan(reads);
+ expect(terminalEvents(events)).toEqual([]);
+ harness.finishExecution();
+ await expect.poll(() => terminalEvents(events)).toHaveLength(1);
+ } finally {
+ release();
+ await session.close();
+ }
+ });
+
+ test("does not treat shutdown interruption as a completed or canceled turn", async () => {
+ const harness = new V2Harness();
+ harness.autoComplete = false;
+ const client = new OpenCodeV2AgentClient({
+ logger: createTestLogger(),
+ runtime: harness.runtime,
+ });
+ const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
+ const events: AgentStreamEvent[] = [];
+ session.subscribe((event) => events.push(event));
+ try {
+ await session.startTurn("resumable work");
+ await expect.poll(() => harness.prompts).toHaveLength(1);
+ harness.active = false;
+ // Shutdown does not project an idle outcome; the preceding run's outcome can remain.
+ harness.info.outcome = "succeeded";
+ harness.push({
+ id: "shutdown",
+ created: 3,
+ type: "session.execution.interrupted",
+ durable: { aggregateID: "session", seq: 3, version: 1 },
+ data: { sessionID: "session", reason: "shutdown" },
+ });
+ const reads = harness.activeReads;
+ harness.push({ id: "reconnect", created: 4, type: "server.connected", data: {} });
+ await expect.poll(() => harness.activeReads).toBeGreaterThan(reads);
+ expect(terminalEvents(events)).toEqual([]);
+ harness.startExecution();
+ harness.finishExecution();
+ await expect.poll(() => terminalEvents(events)).toHaveLength(1);
+ expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" });
+ } finally {
+ await session.close();
+ }
+ });
+
+ test("recovers the latest execution from the durable log when shutdown and resume events were missed", async () => {
+ const harness = new V2Harness();
+ harness.autoComplete = false;
+ const client = new OpenCodeV2AgentClient({
+ logger: createTestLogger(),
+ runtime: harness.runtime,
+ });
+ const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
+ const events: AgentStreamEvent[] = [];
+ session.subscribe((event) => events.push(event));
+ try {
+ await session.startTurn("resumable work");
+ await expect.poll(() => harness.prompts).toHaveLength(1);
+ harness.active = false;
+ harness.info.outcome = "succeeded";
+ harness.api.session.log = async function* () {
+ yield {
+ id: "shutdown",
+ created: 3,
+ type: "session.execution.interrupted",
+ durable: { aggregateID: "session", seq: 3, version: 1 },
+ data: { sessionID: "session", reason: "shutdown" },
+ };
+ };
+ const reads = harness.activeReads;
+ harness.push({ id: "reconnect", created: 4, type: "server.connected", data: {} });
+ await expect.poll(() => harness.activeReads).toBeGreaterThan(reads);
+ expect(terminalEvents(events)).toEqual([]);
+ harness.api.session.log = async function* () {
+ yield {
+ id: "done",
+ created: 6,
+ type: "session.execution.succeeded",
+ durable: { aggregateID: "session", seq: 6, version: 1 },
+ data: { sessionID: "session" },
+ };
+ };
+ harness.push({
+ id: "reconnect-after-resume",
+ created: 7,
+ type: "server.connected",
+ data: {},
+ });
+ await expect.poll(() => terminalEvents(events)).toHaveLength(1);
+ expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" });
+ } finally {
+ await session.close();
+ }
+ });
+
+ test("keeps the turn alive through an observation transport failure", async () => {
+ const harness = new V2Harness();
+ harness.autoComplete = false;
+ const client = new OpenCodeV2AgentClient({
+ logger: createTestLogger(),
+ runtime: harness.runtime,
+ });
+ const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
+ const events: AgentStreamEvent[] = [];
+ session.subscribe((event) => events.push(event));
+ const active = harness.api.session.active;
+ let attempted = false;
+ harness.api.session.active = async () => {
+ attempted = true;
+ throw new Error("fetch failed");
+ };
+ try {
+ await session.startTurn("work");
+ await expect.poll(() => attempted).toBe(true);
+ expect(terminalEvents(events)).toEqual([]);
+ harness.api.session.active = active;
+ harness.finishExecution();
+ await expect.poll(() => terminalEvents(events)).toHaveLength(1);
+ expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" });
+ } finally {
+ await session.close();
+ }
+ });
+
test.each([
{ variants: ["medium", "custom"], selected: "medium", expected: "medium" },
{ variants: ["custom"], selected: "custom", expected: "custom" },
@@ -169,7 +451,7 @@ describe("OpenCode v2 session lifecycle", () => {
test("reconnects after a helper exits and restores session configuration on the next turn", async () => {
const first = new V2Harness();
- const second = new V2Harness();
+ const second = new V2Harness(1);
let exit!: (error: Error) => void;
const exited = new Promise((resolve) => {
exit = resolve;
@@ -185,7 +467,7 @@ describe("OpenCode v2 session lifecycle", () => {
shutdown: async () => undefined,
},
});
- first.wait = () => new Promise(() => undefined);
+ first.autoComplete = false;
second.prompt = async (input) => {
second.history.push({
id: "answer",
@@ -361,11 +643,8 @@ describe("OpenCode v2 session lifecycle", () => {
test("reconciles reconnect snapshots without replaying text or losing repeated chunks", async () => {
const harness = new V2Harness();
- let finish!: () => void;
- harness.wait = () =>
- new Promise((resolve) => {
- finish = resolve;
- });
+ harness.autoComplete = false;
+ const finish = () => harness.finishExecution();
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
@@ -378,7 +657,7 @@ describe("OpenCode v2 session lifecycle", () => {
});
try {
const running = session.run("laugh");
- await expect.poll(() => typeof finish).toBe("function");
+ await expect.poll(() => harness.prompts).toEqual(["laugh"]);
const answer = {
id: "answer",
type: "assistant",
@@ -401,7 +680,7 @@ describe("OpenCode v2 session lifecycle", () => {
}
});
- test("reports a terminal failure when reading the execution error fails", async () => {
+ test("retries observation failures without replacing the execution error", async () => {
const harness = new V2Harness();
harness.info.outcome = "failed";
const client = new OpenCodeV2AgentClient({
@@ -412,9 +691,27 @@ describe("OpenCode v2 session lifecycle", () => {
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
+ let attempted = false;
+ harness.api.session.log = () => {
+ attempted = true;
+ throw new Error("log unavailable");
+ };
await session.startTurn("hello");
const failures = () => events.filter((event) => event.type === "turn_failed");
+ await expect.poll(() => attempted).toBe(true);
+ expect(failures()).toEqual([]);
+ harness.api.session.log = async function* () {
+ yield {
+ id: "failure",
+ created: 2,
+ durable: { aggregateID: "session", seq: 2, version: 1 },
+ type: "session.execution.failed",
+ data: { sessionID: "session", error: { type: "provider", message: "quota exhausted" } },
+ };
+ };
+ harness.push({ id: "reconnect", created: 3, type: "server.connected", data: {} });
await expect.poll(failures).toHaveLength(1);
+ expect(failures()[0]).toMatchObject({ error: "quota exhausted" });
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(0);
} finally {
await session.close();
@@ -424,16 +721,13 @@ describe("OpenCode v2 session lifecycle", () => {
test("waits for prompt acceptance before interrupting", async () => {
const harness = new V2Harness();
let accept!: () => void;
- let settle!: () => void;
+ const settle = () => harness.finishExecution("interrupted");
let interruptions = 0;
harness.prompt = () =>
new Promise((resolve) => {
accept = resolve;
});
- harness.wait = () =>
- new Promise((resolve) => {
- settle = resolve;
- });
+
harness.interrupt = async () => {
interruptions += 1;
harness.info.outcome = "interrupted";
@@ -494,12 +788,9 @@ describe("OpenCode v2 session lifecycle", () => {
test("waits for interruption settlement before submitting replacement work", async () => {
const harness = new V2Harness();
- let finishFirst!: () => void;
+ harness.autoComplete = false;
+ const finishFirst = () => harness.finishExecution("interrupted");
let finishStop!: () => void;
- harness.wait = () =>
- new Promise((resolve) => {
- finishFirst = resolve;
- });
harness.interrupt = () =>
new Promise((resolve) => {
finishStop = () => {
@@ -515,12 +806,12 @@ describe("OpenCode v2 session lifecycle", () => {
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
await session.startTurn("first");
- await expect.poll(() => typeof finishFirst).toBe("function");
+ await expect.poll(() => harness.prompts).toEqual(["first"]);
const stopping = session.interrupt();
const replacement = session.startTurn("second");
await Promise.resolve();
expect(harness.prompts).toEqual(["first"]);
- harness.wait = async () => undefined;
+ harness.autoComplete = true;
finishStop();
await stopping;
await replacement;
@@ -587,11 +878,8 @@ describe("OpenCode v2 session lifecycle", () => {
test("refuses replacement work after a failed stop until Stop succeeds", async () => {
const harness = new V2Harness();
- let finishFirst!: () => void;
- harness.wait = () =>
- new Promise((resolve) => {
- finishFirst = resolve;
- });
+ harness.autoComplete = false;
+ const finishFirst = () => harness.finishExecution("interrupted");
harness.interrupt = async () => {
throw new Error("stop failed");
};
@@ -602,7 +890,7 @@ describe("OpenCode v2 session lifecycle", () => {
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
await session.startTurn("first");
- await expect.poll(() => typeof finishFirst).toBe("function");
+ await expect.poll(() => harness.prompts).toEqual(["first"]);
await expect(session.interrupt()).rejects.toThrow("stop failed");
await expect(session.startTurn("unsafe replacement")).rejects.toThrow("stop failed");
expect(harness.prompts).toEqual(["first"]);
@@ -611,7 +899,7 @@ describe("OpenCode v2 session lifecycle", () => {
return { interrupted: true };
};
await session.interrupt();
- harness.wait = async () => undefined;
+ harness.autoComplete = true;
await session.startTurn("retry");
await expect.poll(() => harness.prompts).toEqual(["first", "retry"]);
} finally {
diff --git a/packages/server/src/server/agent/providers/opencode/v2/api.ts b/packages/server/src/server/agent/providers/opencode/v2/api.ts
index 1e656e6603..fe225cade2 100644
--- a/packages/server/src/server/agent/providers/opencode/v2/api.ts
+++ b/packages/server/src/server/agent/providers/opencode/v2/api.ts
@@ -16,7 +16,6 @@ export interface V2Api {
| "prompt"
| "command"
| "compact"
- | "wait"
| "interrupt"
| "revert"
| "log"
diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts
index 451bdf81c6..50a6d98549 100644
--- a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts
+++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts
@@ -66,6 +66,71 @@ describe("OpenCode v2 questions", () => {
});
});
+describe("OpenCode v2 question tool", () => {
+ test("shows the question text and accepts a typed answer", async () => {
+ const harness = new V2Harness();
+ harness.api.session.form.list = async () => [
+ {
+ id: "question",
+ sessionID: "session",
+ title: "Questions",
+ fields: [
+ {
+ key: "q0",
+ title: "Next task",
+ description: "What would you like me to work on next?",
+ type: "string",
+ options: [
+ { value: "Explore", label: "Explore", description: "Map the codebase" },
+ { value: "Fix a bug", label: "Fix a bug", description: "Track down a bug" },
+ ],
+ custom: true,
+ },
+ ],
+ },
+ ];
+ const answers: Parameters[0][] = [];
+ harness.api.session.form.reply = async (input) => {
+ answers.push(input);
+ };
+ const client = new OpenCodeV2AgentClient({
+ logger: createTestLogger(),
+ runtime: harness.runtime,
+ });
+ const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
+ try {
+ const [request] = session.getPendingPermissions();
+ expect(request.input).toEqual({
+ questions: [
+ {
+ header: "Next task",
+ question: "What would you like me to work on next?",
+ options: [
+ { value: "Explore", label: "Explore", description: "Map the codebase" },
+ { value: "Fix a bug", label: "Fix a bug", description: "Track down a bug" },
+ ],
+ multiple: false,
+ allowOther: true,
+ },
+ ],
+ });
+ await session.respondToPermission("question", {
+ behavior: "allow",
+ updatedInput: { answers: { "Next task": "Write the release notes" } },
+ });
+ expect(answers).toEqual([
+ {
+ sessionID: "session",
+ formID: "question",
+ answer: { q0: "Write the release notes" },
+ },
+ ]);
+ } finally {
+ await session.close();
+ }
+ });
+});
+
describe("OpenCode v2 permission routing", () => {
test("routes a child approval back to its owning session", async () => {
const harness = new V2Harness();
diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts
index fcd0213282..176fc455be 100644
--- a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts
+++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts
@@ -100,10 +100,11 @@ export class SessionPermissions {
title: form.title,
input: {
questions: form.fields.map((field) => ({
- header: field.key,
- question: field.title ?? field.key,
+ header: field.title ?? field.key,
+ question: field.description ?? field.title ?? field.key,
options: "options" in field ? field.options : undefined,
multiple: field.type === "multiselect",
+ allowOther: "custom" in field && field.custom === true,
})),
},
};
diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts
index 60ede6b3f5..f7a19006d9 100644
--- a/packages/server/src/server/agent/providers/opencode/v2/session.ts
+++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts
@@ -91,6 +91,11 @@ export class OpenCodeV2Session implements AgentSession {
await this.reconcile();
return { info: this.info, history: this.history };
},
+ reportReconciliationError: (error) =>
+ this.logger.warn(
+ { error: toDiagnosticErrorMessage(error) },
+ "OpenCode turn reconciliation failed; retrying",
+ ),
clearPermissions: async () => {
for (const request of this.permissions.list())
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
@@ -417,8 +422,7 @@ export class OpenCodeV2Session implements AgentSession {
await this.client.session.environment({ sessionID: this.id, variables: this.launchEnv });
await this.reconcile();
await this.children.reconcile(this.id);
- const active = await this.client.session.active();
- if (active[this.id]) this.turns.observeActiveTurn();
+ await this.turns.reconcile();
}
private scheduleReconcile() {
if (this.refreshTimer || this.closed) return;
@@ -461,11 +465,8 @@ export class OpenCodeV2Session implements AgentSession {
return;
}
this.permissions.observe(event);
- if (event.type === "session.execution.failed")
- this.turns.executionError = event.data.error.message;
+ this.turns.observe(event);
this.scheduleReconcile();
- if (event.type !== "session.execution.started" || this.turns.id) return;
- this.turns.observeActiveTurn();
}
private async consume(ready: () => void, fail: (error: unknown) => void) {
const signal = AbortSignal.any([this.abort.signal, this.streamAbort.signal]);
@@ -502,6 +503,7 @@ export class OpenCodeV2Session implements AgentSession {
if (this.closed) return;
this.closed = true;
this.abort.abort();
+ this.turns.close();
this.streamAbort.abort();
if (this.refreshTimer) clearTimeout(this.refreshTimer);
await this.stream;
diff --git a/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts b/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts
index 7f4524b136..aa74faa6cf 100644
--- a/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts
+++ b/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts
@@ -121,11 +121,8 @@ describe("OpenCode v2 token streaming", () => {
test("withholds streamed prose during a structured-output turn", async () => {
const harness = new V2Harness();
- let settle!: () => void;
- harness.wait = () =>
- new Promise((resolve) => {
- settle = resolve;
- });
+ harness.autoComplete = false;
+ const settle = () => harness.finishExecution();
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
diff --git a/packages/server/src/server/agent/providers/opencode/v2/turns.ts b/packages/server/src/server/agent/providers/opencode/v2/turns.ts
index 5b4611b8f8..5c8765ec02 100644
--- a/packages/server/src/server/agent/providers/opencode/v2/turns.ts
+++ b/packages/server/src/server/agent/providers/opencode/v2/turns.ts
@@ -1,5 +1,5 @@
import { structuredOutput } from "./structured-output.js";
-import type { SessionInfo, SessionMessageInfo } from "@opencode/client";
+import type { OpenCodeEvent, SessionInfo, SessionMessageInfo } from "@opencode/client";
import { randomUUID } from "node:crypto";
@@ -20,6 +20,25 @@ import { usageFromV2 } from "./mapping.js";
import { commands } from "./commands.js";
import type { V2Api } from "./api.js";
+type ExecutionEvent = Extract<
+ OpenCodeEvent,
+ {
+ type:
+ | "session.execution.started"
+ | "session.execution.succeeded"
+ | "session.execution.failed"
+ | "session.execution.interrupted";
+ }
+>;
+function isExecutionEvent(event: { type: string }): event is ExecutionEvent {
+ return (
+ event.type === "session.execution.started" ||
+ event.type === "session.execution.succeeded" ||
+ event.type === "session.execution.failed" ||
+ event.type === "session.execution.interrupted"
+ );
+}
+
interface TurnSnapshot {
info: SessionInfo;
history: SessionMessageInfo[];
@@ -32,19 +51,27 @@ interface TurnOptions {
emit(event: AgentStreamEvent): void;
reconcile(): Promise;
clearPermissions(): Promise;
+ reportReconciliationError(error: unknown): void;
}
interface Turn {
output?: ReturnType;
id: string;
submitted: Promise;
completion: Promise;
+ settle(): void;
+ accepted: boolean;
}
export class SessionTurns {
private turn: Turn | null = null;
private stopping: Promise | null = null;
private stopFailed = false;
- executionError: string | null = null;
+ private revision = 0;
+ private execution: ExecutionEvent | null = null;
+ private sequence = 0;
+ private refresh: Promise | null = null;
+ private dirty = false;
+ private retryTimer: ReturnType | null = null;
constructor(private readonly options: TurnOptions) {}
get id() {
return this.turn?.id;
@@ -54,21 +81,49 @@ export class SessionTurns {
}
fail(error: Error) {
const turn = this.turn;
+ if (!turn) return;
+ this.release(turn);
+ this.options.emit({
+ type: "turn_failed",
+ provider: "opencode",
+ turnId: turn.id,
+ error: toDiagnosticErrorMessage(error),
+ });
+ }
+ private release(turn: Turn) {
this.turn = null;
- if (turn)
- this.options.emit({
- type: "turn_failed",
- provider: "opencode",
- turnId: turn.id,
- error: error.message,
- });
+ this.revision += 1;
+ if (this.retryTimer) clearTimeout(this.retryTimer);
+ this.retryTimer = null;
+ turn.settle();
+ }
+ close() {
+ if (this.turn) this.release(this.turn);
+ }
+ private createTurn(submitted: Promise, accepted: boolean, output?: Turn["output"]): Turn {
+ let settle!: () => void;
+ const completion = new Promise((resolve) => {
+ settle = resolve;
+ });
+ const turn: Turn = {
+ id: randomUUID(),
+ submitted,
+ completion,
+ settle,
+ accepted,
+ output,
+ };
+ this.turn = turn;
+ this.execution = null;
+ this.revision += 1;
+ this.options.emit({ type: "turn_started", provider: "opencode", turnId: turn.id });
+ this.scheduleRecovery();
+ return turn;
}
async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions) {
await this.stopping;
if (this.options.signal.aborted) throw new Error("OpenCode session is closed");
if (this.turn) throw new Error("OpenCode session already has an active turn");
- this.executionError = null;
- const id = randomUUID();
const input = this.promptInput(prompt);
let accept!: () => void;
const submitted = new Promise((resolve) => {
@@ -76,38 +131,106 @@ export class SessionTurns {
});
const output =
options?.outputSchema === undefined ? undefined : structuredOutput(options.outputSchema);
- const completion = this.submit(id, input, accept, options, output);
- this.turn = { id, submitted, completion, output };
- return { turnId: id };
+ const turn = this.createTurn(submitted, false, output);
+ void this.submit(turn, input, accept, options);
+ return { turnId: turn.id };
}
private async submit(
- id: string,
+ turn: Turn,
input: ReturnType,
accept: () => void,
options?: AgentRunOptions,
- output?: ReturnType,
) {
- // Defer until startTurn publishes ownership, including for immediately resolved test transports.
- await Promise.resolve();
- this.options.emit({ type: "turn_started", provider: "opencode", turnId: id });
try {
- await this.dispatch(input, options, output);
- accept();
- await this.finish(id);
+ await this.dispatch(input, options, turn.output);
+ if (this.turn !== turn) return;
+ turn.accepted = true;
+ this.revision += 1;
+ // Covers a fast execution whose terminal event preceded the admission response.
+ this.requestReconciliation();
} catch (error) {
- if (this.turn?.id === id) {
- this.turn = null;
- this.options.emit({
- type: "turn_failed",
- provider: "opencode",
- turnId: id,
- error: toDiagnosticErrorMessage(error),
- });
- }
+ if (this.turn === turn) this.fail(new Error(toDiagnosticErrorMessage(error)));
} finally {
accept();
}
}
+ observe(event: OpenCodeEvent) {
+ if (!isExecutionEvent(event) || event.durable.seq <= this.sequence) return;
+ this.revision += 1;
+ if (event.type === "session.execution.started") this.observeActiveTurn();
+ this.execution = event;
+ this.sequence = event.durable.seq;
+ this.requestReconciliation();
+ }
+ private requestReconciliation() {
+ void this.reconcile().catch((error: unknown) => this.options.reportReconciliationError(error));
+ }
+ // Recovery reads state; a quiet stream (including SSE comment heartbeats) is not a reason to reconnect.
+ private scheduleRecovery() {
+ if (this.retryTimer || !this.turn || this.options.signal.aborted) return;
+ this.retryTimer = setTimeout(() => {
+ this.retryTimer = null;
+ this.requestReconciliation();
+ }, 5_000);
+ this.retryTimer.unref();
+ }
+ reconcile(): Promise {
+ this.dirty = true;
+ if (this.refresh) return this.refresh;
+ this.refresh = Promise.resolve()
+ .then(() => this.drainReconciliation())
+ .finally(() => {
+ this.refresh = null;
+ this.scheduleRecovery();
+ });
+ return this.refresh;
+ }
+ private async drainReconciliation() {
+ while (this.dirty && !this.options.signal.aborted) {
+ this.dirty = false;
+ await this.reconcileTurn();
+ }
+ }
+ private async reconcileTurn() {
+ const revision = this.revision;
+ const turn = this.turn;
+ const active = await this.options.client().session.active({ signal: this.options.signal });
+ if (revision !== this.revision) {
+ this.dirty = true;
+ return;
+ }
+ if (active[this.options.id]) {
+ this.observeActiveTurn();
+ return;
+ }
+ if (!turn?.accepted) return;
+ // Idle outcome survives shutdown, and /event has no replay. Recover the latest
+ // durable execution even when its start or terminal was lost across reconnect.
+ const execution = await this.readExecution();
+ if (revision !== this.revision) {
+ this.dirty = true;
+ return;
+ }
+ if (!execution) return;
+ this.execution = execution;
+ this.sequence = execution.durable.seq;
+ if (
+ execution.type === "session.execution.started" ||
+ (execution.type === "session.execution.interrupted" && execution.data.reason === "shutdown")
+ )
+ return;
+ // Read the final history only after observing idle. Never turn an observation transport error into an execution failure.
+ const snapshot = await this.options.reconcile();
+ if (revision !== this.revision) {
+ this.dirty = true;
+ return;
+ }
+ try {
+ this.finish(turn, snapshot, execution);
+ } catch (error) {
+ if (this.turn === turn) this.fail(new Error(toDiagnosticErrorMessage(error)));
+ }
+ }
private async dispatch(
input: ReturnType,
options?: AgentRunOptions,
@@ -147,51 +270,43 @@ export class SessionTurns {
},
});
}
- private async finish(id: string) {
- await this.options
- .client()
- .session.wait({ sessionID: this.options.id }, { signal: this.options.signal });
- const { info, history } = await this.options.reconcile();
- if (this.turn?.id !== id) return;
- if (info.outcome !== "failed" && info.outcome !== "interrupted")
- this.turn.output?.assert(history);
- const failure =
- info.outcome === "failed" ? (this.executionError ?? (await this.readExecutionError())) : null;
- if (this.turn?.id !== id) return;
- this.turn = null;
- if (info.outcome === "interrupted")
+ private finish(turn: Turn, { info, history }: TurnSnapshot, execution: ExecutionEvent) {
+ if (execution.type === "session.execution.succeeded") turn.output?.assert(history);
+ this.release(turn);
+ if (execution.type === "session.execution.interrupted")
this.options.emit({
type: "turn_canceled",
provider: "opencode",
- turnId: id,
+ turnId: turn.id,
reason: "OpenCode interrupted execution",
});
- else if (info.outcome === "failed")
+ else if (execution.type === "session.execution.failed")
this.options.emit({
type: "turn_failed",
provider: "opencode",
- turnId: id,
- error: failure ?? "OpenCode execution failed",
+ turnId: turn.id,
+ error: execution.data.error.message,
});
else
this.options.emit({
type: "turn_completed",
provider: "opencode",
- turnId: id,
+ turnId: turn.id,
usage: usageFromV2(info),
});
}
- private async readExecutionError(): Promise {
- let message = "OpenCode execution failed";
+ private async readExecution(): Promise {
+ let execution = this.execution;
for await (const event of this.options
.client()
.session.log(
- { sessionID: this.options.id, follow: false },
+ { sessionID: this.options.id, follow: false, after: this.sequence },
{ signal: this.options.signal },
)) {
- if (event.type === "session.execution.failed") message = event.data.error.message;
+ if (isExecutionEvent(event) && event.durable.seq > (execution?.durable.seq ?? this.sequence))
+ execution = event;
}
- return message;
+ return execution;
}
private promptInput(prompt: AgentPromptInput) {
if (typeof prompt === "string") return { text: prompt, files: [] };
@@ -229,6 +344,7 @@ export class SessionTurns {
// A stop sent before the queued prompt is accepted would interrupt an idle session.
await turn?.submitted;
await this.options.client().session.interrupt({ sessionID: this.options.id });
+ await this.reconcile();
await turn?.completion;
})();
this.stopping = stop;
@@ -241,22 +357,8 @@ export class SessionTurns {
}
} else await this.stopping;
}
- observeActiveTurn() {
- if (this.turn) return;
- const id = randomUUID();
- const completion = Promise.resolve()
- .then(() => this.finish(id))
- .catch((error: unknown) => {
- if (this.turn?.id !== id) return;
- this.turn = null;
- this.options.emit({
- type: "turn_failed",
- provider: "opencode",
- turnId: id,
- error: toDiagnosticErrorMessage(error),
- });
- });
- this.turn = { id, submitted: Promise.resolve(), completion };
- this.options.emit({ type: "turn_started", provider: "opencode", turnId: id });
+ private observeActiveTurn() {
+ if (this.turn || this.options.signal.aborted) return;
+ this.createTurn(Promise.resolve(), true);
}
}