mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-20 00:02:54 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2291f54dc5 | ||
|
|
5eaf6d5464 | ||
|
|
2838922ade | ||
|
+1 |
264f6e1cca | ||
|
|
cc744cc669 | ||
|
|
aff476b01b | ||
|
|
d596861891 | ||
|
|
be2fb1cab9 | ||
|
|
87818812ad | ||
|
|
6dc17b2f1b | ||
|
|
052974a308 |
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ Terminology:
|
||||
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`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 `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; 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` `<Button>` 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`).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
@@ -18,6 +18,7 @@ import {
|
||||
expectAttachButtonDisabled,
|
||||
fillComposerDraft,
|
||||
dropFileOnComposer,
|
||||
pasteClipboardIntoComposer,
|
||||
sendDraftToQueue,
|
||||
expectQueuedMessageButton,
|
||||
startRunningMockAgent,
|
||||
@@ -53,6 +54,43 @@ const TEST_JSON = {
|
||||
};
|
||||
|
||||
test.describe("Composer attachments", () => {
|
||||
test("pasting copied document text keeps the text and attaches its image", async ({
|
||||
page,
|
||||
context,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "paste-document-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
const text = "Copied paragraph with 한글.\nSecond line.";
|
||||
|
||||
await pasteClipboardIntoComposer(page, context, {
|
||||
text,
|
||||
html: "<p>Copied paragraph with 한글.</p><p>Second line.</p>",
|
||||
png: MINIMAL_PNG,
|
||||
});
|
||||
|
||||
await expectComposerDraft(page, text);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
});
|
||||
|
||||
test("pasting an image without text attaches it and leaves the draft empty", async ({
|
||||
page,
|
||||
context,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "paste-image-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await pasteClipboardIntoComposer(page, context, { png: MINIMAL_PNG });
|
||||
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expectComposerDraft(page, "");
|
||||
});
|
||||
|
||||
test("selected file shows a loading attachment until upload is acknowledged", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
seedRunningMockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
|
||||
const REPORT_ID = "fixture:fixture-account";
|
||||
@@ -46,30 +42,6 @@ async function openMockAgent(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("usage composer pill", () => {
|
||||
test("shows the report when an existing running chat opens", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42)]],
|
||||
});
|
||||
const session = await seedRunningMockAgentWorkspace({
|
||||
repoPrefix: "usage-running-",
|
||||
title: "Running usage pill",
|
||||
initialPrompt: "Show usage during the turn.",
|
||||
model: "one-minute-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByTestId("usage-composer-pill")).toContainText("42%", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the cached report without a request and shows when it was fetched", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
||||
@@ -172,6 +172,30 @@ export async function dropFileOnComposer(
|
||||
await dataTransfer.dispose();
|
||||
}
|
||||
|
||||
/** Puts the given formats on the system clipboard together, then pastes them into the composer with the keyboard shortcut. */
|
||||
export async function pasteClipboardIntoComposer(
|
||||
page: Page,
|
||||
context: BrowserContext,
|
||||
clipboard: { text?: string; html?: string; png?: Buffer },
|
||||
): Promise<void> {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.evaluate(
|
||||
async ({ text, html, pngBase64 }) => {
|
||||
const formats: Record<string, Blob> = {};
|
||||
if (text !== undefined) formats["text/plain"] = new Blob([text], { type: "text/plain" });
|
||||
if (html !== undefined) formats["text/html"] = new Blob([html], { type: "text/html" });
|
||||
if (pngBase64 !== undefined) {
|
||||
const bytes = Uint8Array.from(atob(pngBase64), (char) => char.charCodeAt(0));
|
||||
formats["image/png"] = new Blob([bytes], { type: "image/png" });
|
||||
}
|
||||
await navigator.clipboard.write([new ClipboardItem(formats)]);
|
||||
},
|
||||
{ text: clipboard.text, html: clipboard.html, pngBase64: clipboard.png?.toString("base64") },
|
||||
);
|
||||
await composerInput(page).click();
|
||||
await page.keyboard.press("ControlOrMeta+v");
|
||||
}
|
||||
|
||||
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
|
||||
export async function removeAttachmentPill(
|
||||
page: Page,
|
||||
|
||||
@@ -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<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
}
|
||||
|
||||
export async function setContentWidth(page: Page, width: number): Promise<void> {
|
||||
const input = contentWidthInput(page);
|
||||
await input.fill(String(width));
|
||||
await input.press("Tab");
|
||||
}
|
||||
|
||||
export async function resetContentWidth(page: Page): Promise<void> {
|
||||
await resetContentWidthButton(page).click();
|
||||
}
|
||||
|
||||
export async function expectDefaultContentWidthSetting(page: Page, width: number): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<unknown> {
|
||||
return page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null
|
||||
? undefined
|
||||
: (JSON.parse(raw) as { contentMaxWidth?: unknown }).contentMaxWidth;
|
||||
}, APP_SETTINGS_KEY);
|
||||
}
|
||||
@@ -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<string>(),
|
||||
@@ -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) => (
|
||||
<RetainedPanelActivity active={active}>{strategy.render(input)}</RetainedPanelActivity>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -92,6 +92,8 @@ export interface StreamRenderInput {
|
||||
listStyle: StyleProp<ViewStyle>;
|
||||
baseListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
forwardListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
// The chat column's max width. The web viewport estimates unmeasured row heights with it.
|
||||
contentMaxWidth: number;
|
||||
}
|
||||
|
||||
export interface ResolveStreamRenderStrategyInput {
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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<AgentStreamViewHandle, AgentStreamVi
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
|
||||
const contentMaxWidth = useSettings(resolveContentMaxWidth);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const pendingClientMessageIds = useMemo(
|
||||
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
|
||||
@@ -1129,6 +1130,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
listStyle: stylesheet.list,
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
contentMaxWidth,
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
<ChatOutlineRail
|
||||
@@ -1622,7 +1624,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
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],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -37,6 +37,7 @@ interface FakeTheme {
|
||||
"4xl": number;
|
||||
};
|
||||
lineHeight: { diff: number };
|
||||
contentMaxWidth: number;
|
||||
colors: { foreground: string; syntax: Record<string, string> };
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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: "" }));
|
||||
|
||||
|
||||
@@ -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) },
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.card} testID={testID}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
</View>
|
||||
<View style={styles.actions}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The two things the sidebar's workspace list says when it has no rows.
|
||||
*
|
||||
@@ -27,13 +49,15 @@ export function SidebarFilterEmptyState() {
|
||||
}, [clearLabelFilter, clearProjectFilters]);
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="sidebar-filter-empty-state">
|
||||
<Text style={styles.title}>{t("sidebar.filterEmpty.title")}</Text>
|
||||
<Text style={styles.description}>{t("sidebar.filterEmpty.description")}</Text>
|
||||
<Button variant="ghost" size="sm" onPress={clearFilters}>
|
||||
<SidebarEmptyStateCard
|
||||
testID="sidebar-filter-empty-state"
|
||||
title={t("sidebar.filterEmpty.title")}
|
||||
description={t("sidebar.filterEmpty.description")}
|
||||
>
|
||||
<Button variant="secondary" size="xs" onPress={clearFilters}>
|
||||
{t("sidebar.filterEmpty.clear")}
|
||||
</Button>
|
||||
</View>
|
||||
</SidebarEmptyStateCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,40 +71,44 @@ export function SidebarProjectEmptyState({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="sidebar-project-empty-state">
|
||||
<Text style={styles.title}>{t("sidebar.project.empty.title")}</Text>
|
||||
<Text style={styles.description}>{t("sidebar.project.empty.description")}</Text>
|
||||
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
|
||||
<SidebarEmptyStateCard
|
||||
testID="sidebar-project-empty-state"
|
||||
title={t("sidebar.project.empty.title")}
|
||||
description={t("sidebar.project.empty.description")}
|
||||
>
|
||||
<Button variant="secondary" size="xs" leftIcon={Plus} onPress={onAddProject}>
|
||||
{t("sidebar.actions.addProject")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" leftIcon={Import} onPress={onImportSession}>
|
||||
<Button variant="outline" size="xs" leftIcon={Import} onPress={onImportSession}>
|
||||
{t("importSession.title")}
|
||||
</Button>
|
||||
</View>
|
||||
</SidebarEmptyStateCard>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<KeyboardTranslateView style={dockStyles.centered}>
|
||||
<KeyboardTranslateView style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
|
||||
{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 },
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -475,7 +475,9 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
|
||||
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
// Copied text often carries an image of itself, as from Word. Let the
|
||||
// text paste normally and attach the images alongside it.
|
||||
if (!event.clipboardData?.getData("text/plain")) event.preventDefault();
|
||||
|
||||
void filesToImageAttachments(imageFiles)
|
||||
.then((pastedAttachments) => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.outerGutter}>
|
||||
<View style={styles.readingFrame}>
|
||||
<View style={styles.readingFrame} testID="markdown-preview-frame">
|
||||
{document.frontMatter.length > 0 ? (
|
||||
<View style={styles.frontMatterTable} testID="markdown-front-matter">
|
||||
{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],
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AppSettings, "contentMaxWidth">): number {
|
||||
return settings.contentMaxWidth ?? DEFAULT_CONTENT_MAX_WIDTH;
|
||||
}
|
||||
|
||||
export function parseClampedFontSize(
|
||||
value: unknown,
|
||||
bounds: { min: number; max: number },
|
||||
|
||||
@@ -2191,6 +2191,14 @@ export const ar: TranslationResources = {
|
||||
codeSizeHint: "يُستخدم للكود والفروقات ومخرجات الطرفية",
|
||||
codeSizeAccessibility: "حجم خط الكود",
|
||||
},
|
||||
layout: {
|
||||
title: "التخطيط",
|
||||
contentWidth: "عرض المحتوى",
|
||||
contentWidthHint: "أقصى عرض للمحادثة وملفات Markdown على الشاشات العريضة",
|
||||
contentWidthAccessibility: "عرض المحتوى بالبكسل",
|
||||
reset: "إعادة تعيين",
|
||||
resetAccessibility: "إعادة عرض المحتوى إلى الافتراضي",
|
||||
},
|
||||
syntax: {
|
||||
title: "بناء الجملة",
|
||||
highlightTheme: "تسليط الضوء على الموضوع",
|
||||
|
||||
@@ -2315,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2211,6 +2211,14 @@ export const ja: TranslationResources = {
|
||||
codeSizeHint: "コード、差分、ターミナル出力に使用されます",
|
||||
codeSizeAccessibility: "コードフォントサイズ",
|
||||
},
|
||||
layout: {
|
||||
title: "レイアウト",
|
||||
contentWidth: "コンテンツ幅",
|
||||
contentWidthHint: "ワイド画面でのチャットと Markdown ファイルの最大幅",
|
||||
contentWidthAccessibility: "コンテンツ幅 (ピクセル)",
|
||||
reset: "リセット",
|
||||
resetAccessibility: "コンテンツ幅をデフォルトに戻す",
|
||||
},
|
||||
syntax: {
|
||||
title: "構文ハイライト",
|
||||
highlightTheme: "ハイライトテーマ",
|
||||
|
||||
@@ -2202,6 +2202,14 @@ export const ko: TranslationResources = {
|
||||
codeSizeHint: "코드, diff 및 터미널 출력에 사용됩니다",
|
||||
codeSizeAccessibility: "코드 글꼴 크기",
|
||||
},
|
||||
layout: {
|
||||
title: "레이아웃",
|
||||
contentWidth: "콘텐츠 너비",
|
||||
contentWidthHint: "와이드 화면에서 채팅과 Markdown 파일의 최대 너비",
|
||||
contentWidthAccessibility: "콘텐츠 너비(픽셀)",
|
||||
reset: "재설정",
|
||||
resetAccessibility: "콘텐츠 너비를 기본값으로 재설정",
|
||||
},
|
||||
syntax: {
|
||||
title: "구문",
|
||||
highlightTheme: "강조 테마",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2231,6 +2231,14 @@ export const ru: TranslationResources = {
|
||||
codeSizeHint: "Используется для кода, диффов и вывода терминала",
|
||||
codeSizeAccessibility: "Размер шрифта кода",
|
||||
},
|
||||
layout: {
|
||||
title: "Макет",
|
||||
contentWidth: "Ширина содержимого",
|
||||
contentWidthHint: "Максимальная ширина чата и файлов Markdown на широких экранах",
|
||||
contentWidthAccessibility: "Ширина содержимого в пикселях",
|
||||
reset: "Сбросить",
|
||||
resetAccessibility: "Сбросить ширину содержимого",
|
||||
},
|
||||
syntax: {
|
||||
title: "Синтаксис",
|
||||
highlightTheme: "Тема подсветки",
|
||||
|
||||
@@ -2166,6 +2166,14 @@ export const zhCN: TranslationResources = {
|
||||
codeSizeHint: "用于代码、差异和终端输出",
|
||||
codeSizeAccessibility: "代码字号",
|
||||
},
|
||||
layout: {
|
||||
title: "布局",
|
||||
contentWidth: "内容宽度",
|
||||
contentWidthHint: "宽屏上聊天和 Markdown 文件的最大宽度",
|
||||
contentWidthAccessibility: "内容宽度(像素)",
|
||||
reset: "重置",
|
||||
resetAccessibility: "将内容宽度重置为默认值",
|
||||
},
|
||||
syntax: {
|
||||
title: "语法",
|
||||
highlightTheme: "高亮主题",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<EditingTextInputHandle>(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 (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.appearance.layout.contentWidth")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.appearance.layout.contentWidthHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.sizeField}>
|
||||
{value === null ? null : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={reset}
|
||||
accessibilityLabel={t("settings.appearance.layout.resetAccessibility")}
|
||||
>
|
||||
{t("settings.appearance.layout.reset")}
|
||||
</Button>
|
||||
)}
|
||||
<TextInput
|
||||
ref={input}
|
||||
initialValue={String(width)}
|
||||
onBlur={commit}
|
||||
onSubmitEditing={commit}
|
||||
keyboardType="number-pad"
|
||||
inputMode="numeric"
|
||||
selectTextOnFocus
|
||||
style={styles.widthInput}
|
||||
accessibilityLabel={t("settings.appearance.layout.contentWidthAccessibility")}
|
||||
/>
|
||||
<Text style={styles.unit}>px</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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() {
|
||||
/>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t("settings.appearance.layout.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<ContentWidthRow
|
||||
value={settings.contentMaxWidth}
|
||||
onChange={handleContentMaxWidthChange}
|
||||
/>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t("settings.appearance.syntax.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<SyntaxRow value={settings.syntaxTheme} onChange={handleSyntaxThemeChange} />
|
||||
@@ -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,
|
||||
|
||||
@@ -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<keyof typeof FONT_SIZE, number>;
|
||||
fontFamily: { ui: string; mono: string };
|
||||
lineHeight: Record<keyof typeof LINE_HEIGHT, number>;
|
||||
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,
|
||||
|
||||
@@ -31,13 +31,8 @@ function usageReportsQueryKey(serverId: string) {
|
||||
return ["usage", "reports", serverId] as const;
|
||||
}
|
||||
|
||||
function agentUsageQueryKey(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
model: string | null,
|
||||
isRunning: boolean,
|
||||
) {
|
||||
return ["usage", "agent", serverId, agentId, model, isRunning] as const;
|
||||
function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) {
|
||||
return ["usage", "agent", serverId, agentId, model] as const;
|
||||
}
|
||||
|
||||
function requireClient(serverId: string) {
|
||||
@@ -165,7 +160,8 @@ export function useUsageByHost(): {
|
||||
/**
|
||||
* The usage report for the account an agent is spending. The daemon resolves the
|
||||
* report ID, which is re-resolved when the model changes (a new key) and when a
|
||||
* turn completes (a new query key). The report itself is read from the shared cache.
|
||||
* turn completes (the query is paused while the agent runs, and its data is always
|
||||
* stale, so resuming refetches). The report itself is read from the shared cache.
|
||||
*/
|
||||
export function useAgentUsage(
|
||||
serverId: string,
|
||||
@@ -180,10 +176,10 @@ export function useAgentUsage(
|
||||
}),
|
||||
);
|
||||
const identity = useFetchQuery({
|
||||
queryKey: agentUsageQueryKey(serverId, agentId, model, isRunning),
|
||||
queryKey: agentUsageQueryKey(serverId, agentId, model),
|
||||
queryFn: async () =>
|
||||
(await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId,
|
||||
enabled: isConnected && isSupported,
|
||||
enabled: isConnected && isSupported && !isRunning,
|
||||
dataShape: "value",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
|
||||
@@ -1,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);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -224,6 +224,12 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
|
||||
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<DesktopDaemonStatus>
|
||||
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",
|
||||
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type AgentRuntimeInfo,
|
||||
type UsageReference,
|
||||
type ImportedTimelineEntry,
|
||||
type ImportableProviderSession,
|
||||
type ListImportableSessionsOptions,
|
||||
@@ -1168,24 +1167,6 @@ export class AgentManager {
|
||||
return agent ? { ...agent } : null;
|
||||
}
|
||||
|
||||
async resolveUsageReference(
|
||||
id: string,
|
||||
): Promise<{ found: boolean; reference: UsageReference | null }> {
|
||||
const agent = this.getAgent(id);
|
||||
const stored = agent?.session ? null : await this.registry?.get(id);
|
||||
const source = stored ?? agent;
|
||||
if (!source) return { found: false, reference: null };
|
||||
const config = stored ? buildStoredAgentConfig(stored) : agent!.config;
|
||||
const reference =
|
||||
(await this.clients.get(source.provider)?.resolveUsageReference?.({
|
||||
config,
|
||||
runtimeInfo: source.runtimeInfo ?? null,
|
||||
persistence: source.persistence ?? null,
|
||||
session: agent?.session ?? null,
|
||||
})) ?? null;
|
||||
return { found: true, reference };
|
||||
}
|
||||
|
||||
async waitForAgentClose(agentId: string): Promise<void> {
|
||||
// Loading during reload must wait for the replacement, not resume another writer.
|
||||
await this.lifecycleMutationTails.get(agentId);
|
||||
|
||||
@@ -673,6 +673,8 @@ export interface AgentSession {
|
||||
/** New provider-owned rows to commit on registration. streamHistory must also
|
||||
* replay them at their original timestamps; restored sessions omit old rows. */
|
||||
readonly initialTimeline?: ImportedTimelineEntry[];
|
||||
/** Resolved at fetch time because model and credentials may change during a session. */
|
||||
getUsageReference?(): Promise<UsageReference | null>;
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||
@@ -748,13 +750,6 @@ export interface ResolveAgentDefaultModeInput {
|
||||
export interface AgentClient {
|
||||
readonly provider: AgentProvider;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
/** Account used by the agent. Resolving this must not start or load a session. */
|
||||
resolveUsageReference?(input: {
|
||||
config: AgentSessionConfig;
|
||||
runtimeInfo: AgentRuntimeInfo | null;
|
||||
persistence: AgentPersistenceHandle | null;
|
||||
session: AgentSession | null;
|
||||
}): Promise<UsageReference | null>;
|
||||
createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
|
||||
@@ -406,12 +406,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
cwd: "/workspace",
|
||||
});
|
||||
try {
|
||||
expect(
|
||||
await client.resolveUsageReference?.({
|
||||
config: { provider: harness.registration.id, cwd: "/workspace" },
|
||||
session,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(await session.getUsageReference?.()).toBeNull();
|
||||
expect(harness.inputs.some((input) => input.type === "session.usage_reference")).toBe(false);
|
||||
} finally {
|
||||
await session.close();
|
||||
|
||||
@@ -897,15 +897,6 @@ class PluginAgentClient implements AgentClient {
|
||||
return agentCapabilities(this.runtime.negotiatedCapabilities);
|
||||
}
|
||||
|
||||
async resolveUsageReference({
|
||||
session,
|
||||
}: {
|
||||
config: AgentSessionConfig;
|
||||
session: AgentSession | null;
|
||||
}) {
|
||||
return session instanceof PluginAgentSession ? session.getPluginUsageReference() : null;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
@@ -1105,7 +1096,7 @@ class PluginAgentSession implements AgentSession {
|
||||
return agentCapabilities(this.bridge.negotiatedCapabilities);
|
||||
}
|
||||
|
||||
getPluginUsageReference(): Promise<UsageReference | null> {
|
||||
getUsageReference(): Promise<UsageReference | null> {
|
||||
return this.bridge.getUsageReference();
|
||||
}
|
||||
|
||||
|
||||
@@ -458,6 +458,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
}
|
||||
},
|
||||
getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()),
|
||||
getUsageReference: inner.getUsageReference?.bind(inner),
|
||||
getAvailableModes: () => inner.getAvailableModes(),
|
||||
getCurrentMode: () => inner.getCurrentMode(),
|
||||
setMode: (modeId) => inner.setMode(modeId),
|
||||
@@ -489,28 +490,13 @@ function wrapClientProvider(
|
||||
const listFeatures = inner.listFeatures?.bind(inner);
|
||||
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
|
||||
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
|
||||
// An alias's live session can carry launch env that is fresher than stored config.
|
||||
const sessions = new WeakMap<AgentSession, AgentSession>();
|
||||
const wrapSession = (session: AgentSession) => {
|
||||
const wrapped = wrapSessionProvider(provider, session);
|
||||
sessions.set(wrapped, session);
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
capabilities: inner.capabilities,
|
||||
resolveUsageReference: inner.resolveUsageReference
|
||||
? ({ config, runtimeInfo, persistence, session }) =>
|
||||
inner.resolveUsageReference!({
|
||||
config: { ...config, provider: inner.provider },
|
||||
runtimeInfo,
|
||||
persistence,
|
||||
session: session ? (sessions.get(session) ?? null) : null,
|
||||
})
|
||||
: undefined,
|
||||
createSession: async (config, launchContext) =>
|
||||
wrapSession(
|
||||
wrapSessionProvider(
|
||||
provider,
|
||||
await inner.createSession(
|
||||
{
|
||||
...config,
|
||||
@@ -520,7 +506,8 @@ function wrapClientProvider(
|
||||
),
|
||||
),
|
||||
resumeSession: async (handle, overrides, launchContext, options) =>
|
||||
wrapSession(
|
||||
wrapSessionProvider(
|
||||
provider,
|
||||
await inner.resumeSession(
|
||||
{
|
||||
...handle,
|
||||
@@ -582,7 +569,7 @@ function wrapClientProvider(
|
||||
}
|
||||
return {
|
||||
...imported,
|
||||
session: wrapSession(imported.session),
|
||||
session: wrapSessionProvider(provider, imported.session),
|
||||
config: {
|
||||
...imported.config,
|
||||
provider,
|
||||
|
||||
@@ -224,17 +224,8 @@ function createSessionWithConfig(
|
||||
|
||||
test("ACP usage reference uses the provider ID", async () => {
|
||||
for (const provider of ["copilot", "cursor", "kimi", "custom-source"]) {
|
||||
const client = new ACPAgentClient({
|
||||
provider,
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["unused"],
|
||||
});
|
||||
expect(
|
||||
await client.resolveUsageReference({
|
||||
config: { provider, cwd: "/tmp/paseo-acp-test" },
|
||||
session: null,
|
||||
}),
|
||||
).toEqual({ source: provider, input: {} });
|
||||
const session = createSessionWithConfig({ provider });
|
||||
expect(await session.getUsageReference()).toEqual({ source: provider, input: {} });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -882,9 +882,6 @@ function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): b
|
||||
export class ACPAgentClient implements AgentClient {
|
||||
readonly provider: string;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
async resolveUsageReference() {
|
||||
return { source: this.provider, input: {} };
|
||||
}
|
||||
readonly resolveCreateConfig = resolveACPCreateConfig;
|
||||
readonly isCreateConfigUnattended = isACPCreateConfigUnattended;
|
||||
|
||||
@@ -1636,6 +1633,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
readonly provider: string;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
|
||||
async getUsageReference() {
|
||||
return { source: this.provider, input: {} };
|
||||
}
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly defaultCommand: [string, ...string[]];
|
||||
|
||||
@@ -531,16 +531,16 @@ describe("ClaudeAgentClient binary resolution", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("Claude usage reference follows CLAUDE_CONFIG_DIR and excludes API overrides", async () => {
|
||||
const config = { provider: "claude", cwd: process.cwd() };
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: "/accounts/second" } },
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
expect(await client.resolveUsageReference?.({ config, session: null })).toEqual({
|
||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||
const session = await client.createSession(
|
||||
{ provider: "claude", cwd: process.cwd() },
|
||||
{ env: { CLAUDE_CONFIG_DIR: "/accounts/second" } },
|
||||
);
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
source: "claude",
|
||||
input: { configDir: "/accounts/second" },
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"])(
|
||||
@@ -548,15 +548,14 @@ describe("ClaudeAgentClient binary resolution", () => {
|
||||
async (name) => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
runtimeSettings: { env: { [name]: "override" } },
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
expect(
|
||||
await client.resolveUsageReference?.({
|
||||
config: { provider: "claude", cwd: process.cwd() },
|
||||
session: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
const session = await client.createSession(
|
||||
{ provider: "claude", cwd: process.cwd() },
|
||||
{ env: { [name]: "override" } },
|
||||
);
|
||||
expect(await session.getUsageReference?.()).toBeNull();
|
||||
await session.close();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -570,16 +569,15 @@ describe("ClaudeAgentClient binary resolution", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const client = registry["work-claude"].createClient(logger);
|
||||
expect(
|
||||
await client.resolveUsageReference?.({
|
||||
config: { provider: "work-claude", cwd: process.cwd() },
|
||||
session: null,
|
||||
}),
|
||||
).toEqual({
|
||||
const session = await registry["work-claude"].createClient(logger).createSession({
|
||||
provider: "work-claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
source: "claude",
|
||||
input: { configDir: "/accounts/work" },
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("resolves the installed Claude Code version", async () => {
|
||||
|
||||
@@ -1519,15 +1519,6 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
|
||||
}
|
||||
|
||||
async resolveUsageReference() {
|
||||
const env = createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings });
|
||||
if (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) return null;
|
||||
return {
|
||||
source: "claude",
|
||||
input: { configDir: env.CLAUDE_CONFIG_DIR || path.join(env.HOME || homedir(), ".claude") },
|
||||
};
|
||||
}
|
||||
|
||||
resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition {
|
||||
return resolveConfiguredClaudeModel(model);
|
||||
}
|
||||
@@ -2051,6 +2042,15 @@ class ClaudeAgentSession implements AgentSession {
|
||||
readonly provider = "claude" as const;
|
||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||
|
||||
async getUsageReference() {
|
||||
const env = this.buildSdkEnv();
|
||||
if (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) return null;
|
||||
return {
|
||||
source: "claude",
|
||||
input: { configDir: env.CLAUDE_CONFIG_DIR || path.join(env.HOME || homedir(), ".claude") },
|
||||
};
|
||||
}
|
||||
|
||||
private readonly config: ClaudeAgentConfig;
|
||||
private readonly launchEnv?: Record<string, string>;
|
||||
private readonly agentId?: string;
|
||||
|
||||
@@ -172,18 +172,41 @@ function createSession(
|
||||
}
|
||||
|
||||
test("Codex usage reference follows CODEX_HOME and excludes custom base URLs", async () => {
|
||||
const config = createConfig();
|
||||
const client = new CodexAppServerAgentClient(createTestLogger(), {
|
||||
env: { CODEX_HOME: "/accounts/second" },
|
||||
});
|
||||
expect(await client.resolveUsageReference({ config, session: null })).toEqual({
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig(),
|
||||
null,
|
||||
createTestLogger(),
|
||||
() => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
{},
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
"interactive",
|
||||
{ CODEX_HOME: "/accounts/second" },
|
||||
);
|
||||
expect(await session.getUsageReference()).toEqual({
|
||||
source: "codex",
|
||||
input: { codexHome: "/accounts/second" },
|
||||
});
|
||||
const custom = new CodexAppServerAgentClient(createTestLogger(), {
|
||||
env: { CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" },
|
||||
});
|
||||
expect(await custom.resolveUsageReference({ config, session: null })).toBeNull();
|
||||
const custom = new CodexAppServerAgentSession(
|
||||
createConfig(),
|
||||
null,
|
||||
createTestLogger(),
|
||||
() => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
{},
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
"interactive",
|
||||
{ CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" },
|
||||
);
|
||||
expect(await custom.getUsageReference()).toBeNull();
|
||||
});
|
||||
|
||||
function createProviderWithFakeAppServer(
|
||||
@@ -637,6 +660,7 @@ async function withCustomCodexProviderHome<T>(
|
||||
session: AgentSession;
|
||||
readCaptured: () => CapturedFakeCodexRecord[];
|
||||
}) => Promise<T>,
|
||||
providerPrompts: Record<string, string> = {},
|
||||
): Promise<T> {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-provider-home-"));
|
||||
const daemonCodexHome = path.join(tempDir, "daemon-codex-home");
|
||||
@@ -653,6 +677,9 @@ async function withCustomCodexProviderHome<T>(
|
||||
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,
|
||||
`
|
||||
@@ -730,11 +757,21 @@ async function listPromptCommandsFromCustomCodexHome(): Promise<string[]> {
|
||||
});
|
||||
}
|
||||
|
||||
async function runPromptFromCustomCodexHome(prompt: string): Promise<CapturedFakeCodexRecord[]> {
|
||||
async function runPromptFromCustomCodexHome(
|
||||
prompt: string,
|
||||
providerPrompts: Record<string, string> = {},
|
||||
): Promise<CapturedFakeCodexRecord[]> {
|
||||
return withCustomCodexProviderHome(async ({ session, readCaptured }) => {
|
||||
await session.startTurn(prompt);
|
||||
return readCaptured();
|
||||
});
|
||||
}, providerPrompts);
|
||||
}
|
||||
|
||||
async function expandCustomPrompt(template: string, args: string): Promise<string> {
|
||||
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 {
|
||||
@@ -2461,6 +2498,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([
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
@@ -3456,6 +3452,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private readonly autoReviewEnabled: boolean = false,
|
||||
private readonly agentId?: string,
|
||||
private readonly initialResumePurpose: "interactive" | "history" = "interactive",
|
||||
private readonly usageEnv: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
this.logger = logger.child({
|
||||
module: "agent",
|
||||
@@ -3485,6 +3482,17 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async getUsageReference() {
|
||||
if (this.usageEnv.OPENAI_BASE_URL) return null;
|
||||
return {
|
||||
source: "codex",
|
||||
input: {
|
||||
codexHome:
|
||||
this.usageEnv.CODEX_HOME || path.join(this.usageEnv.HOME || os.homedir(), ".codex"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
return this.currentThreadId;
|
||||
}
|
||||
@@ -7028,15 +7036,6 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
private readonly deps: CodexAppServerAgentDeps = {},
|
||||
) {}
|
||||
|
||||
async resolveUsageReference() {
|
||||
const env = buildCodexAppServerEnv(this.runtimeSettings);
|
||||
if (env.OPENAI_BASE_URL) return null;
|
||||
return {
|
||||
source: "codex",
|
||||
input: { codexHome: env.CODEX_HOME || path.join(env.HOME || os.homedir(), ".codex") },
|
||||
};
|
||||
}
|
||||
|
||||
private sessionDeps(launchEnv: Record<string, string> | undefined): CodexAppServerAgentDeps {
|
||||
return {
|
||||
...this.deps,
|
||||
@@ -7158,6 +7157,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
autoReviewEnabled,
|
||||
launchContext?.agentId,
|
||||
"interactive",
|
||||
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
|
||||
);
|
||||
await session.connect();
|
||||
return session;
|
||||
@@ -7190,6 +7190,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
autoReviewEnabled,
|
||||
launchContext?.agentId,
|
||||
options?.purpose ?? "interactive",
|
||||
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
|
||||
);
|
||||
await session.connect();
|
||||
return session;
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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<AgentStreamEvent["type"]>([
|
||||
"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 }> = [];
|
||||
@@ -194,20 +218,14 @@ describe("OMP agent client and session", () => {
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
);
|
||||
db.prepare("INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?)").run(
|
||||
"session:sticky:openai-codex:omp-session-1",
|
||||
JSON.stringify({ type: "oauth", credentialId: 1 }),
|
||||
Math.floor(Date.now() / 1000) + 100,
|
||||
);
|
||||
db.close();
|
||||
try {
|
||||
const env = {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start({}, undefined, {
|
||||
OMP_PROFILE: "",
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
XDG_DATA_HOME: "",
|
||||
};
|
||||
const omp = new OmpHarness({ env });
|
||||
await omp.start({}, undefined, env);
|
||||
});
|
||||
const runtime = omp.runtime();
|
||||
runtime.state = { ...runtime.state, model: { provider: "openai-codex", id: "codex-model" } };
|
||||
const requestsBefore = runtime.getStateRequestCount;
|
||||
@@ -222,20 +240,6 @@ describe("OMP agent client and session", () => {
|
||||
input: { accessToken: "claude-fixture" },
|
||||
});
|
||||
expect(runtime.getStateRequestCount).toBe(requestsBefore + 2);
|
||||
const beforeClosed = runtime.getStateRequestCount;
|
||||
expect(
|
||||
await omp.getStoredUsageReference("openai-codex/codex-model", "omp-session-1"),
|
||||
).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "codex-fixture", accountId: "codex-account" },
|
||||
});
|
||||
expect(
|
||||
await omp.getStoredUsageReference("openai-codex/codex-model", "omp-session-1", null),
|
||||
).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "codex-fixture", accountId: "codex-account" },
|
||||
});
|
||||
expect(runtime.getStateRequestCount).toBe(beforeClosed);
|
||||
await omp.close();
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
@@ -255,7 +259,7 @@ describe("OMP agent client and session", () => {
|
||||
[expect.objectContaining({ name: "create_agent" })],
|
||||
]);
|
||||
expect(omp.capabilities()).toMatchObject({
|
||||
supportsMcpServers: false,
|
||||
supportsMcpServers: true,
|
||||
supportsNativePaseoTools: true,
|
||||
});
|
||||
});
|
||||
@@ -267,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" });
|
||||
@@ -463,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();
|
||||
@@ -535,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();
|
||||
@@ -560,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();
|
||||
@@ -679,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();
|
||||
@@ -744,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" }]);
|
||||
@@ -795,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 () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,14 @@ function replyToCommands(
|
||||
});
|
||||
}
|
||||
|
||||
function capturePendingCommand(child: OmpChild, type: string): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>): Record<string, unkn
|
||||
}
|
||||
|
||||
describe("OMP CLI runtime", () => {
|
||||
test("steer waits for OMP's response and surfaces rejection", async () => {
|
||||
const child = createOmpChild();
|
||||
let pending: Record<string, unknown> | 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);
|
||||
|
||||
@@ -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<void> {
|
||||
await this.request({
|
||||
type: "compact",
|
||||
...(customInstructions ? { customInstructions } : {}),
|
||||
});
|
||||
await this.request(
|
||||
{
|
||||
type: "compact",
|
||||
...(customInstructions ? { customInstructions } : {}),
|
||||
},
|
||||
JSONL_RPC_NO_TIMEOUT,
|
||||
);
|
||||
}
|
||||
|
||||
async setAutoCompaction(enabled: boolean): Promise<void> {
|
||||
@@ -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<OmpAgentMessage[]> {
|
||||
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<void> {
|
||||
await this.request({ type: "steer", message, ...(images?.length ? { images } : {}) });
|
||||
}
|
||||
|
||||
followUp(
|
||||
|
||||
@@ -5,3 +5,30 @@ type OmpCustomMessage = Extract<OmpAgentMessage, { role: "custom" }>;
|
||||
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:<name> 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}`;
|
||||
}
|
||||
|
||||
@@ -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: "<system-reminder>\nContinue unfinished tasks\n</system-reminder>",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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 = "<irc>\n<from>worker-1</from>\n<message>ready for review</message>\n</irc>";
|
||||
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: "<irc>\n<from>worker-1</from>\n<message>hi</message>\n</irc>",
|
||||
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: "<irc>\n<from>worker-1</from>\n<message>hi</message>\n</irc>",
|
||||
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");
|
||||
|
||||
@@ -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<AgentTimelineItem | null> {
|
||||
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<string, OmpBridgedToolIdentity>;
|
||||
visitedSessionFiles?: Set<string>;
|
||||
}): AsyncGenerator<AgentStreamEvent> {
|
||||
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("<system-reminder>") &&
|
||||
content[0].text.trimEnd().endsWith("</system-reminder>")
|
||||
) {
|
||||
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<string, unknown>): 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;
|
||||
}
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
this.resolveControlledStart = resolve;
|
||||
});
|
||||
private readonly router: OmpHostToolRouter;
|
||||
|
||||
private constructor(
|
||||
private readonly catalog: PaseoToolCatalog,
|
||||
private readonly runtimeSession: Awaited<ReturnType<FakeOmp["startSession"]>>,
|
||||
) {}
|
||||
) {
|
||||
this.router = new OmpHostToolRouter({ runtimeSession, catalog, logger: this.logger });
|
||||
}
|
||||
|
||||
static async withTools(tools: PaseoToolDefinition[]): Promise<OmpHostToolHarness> {
|
||||
const omp = new FakeOmp();
|
||||
@@ -69,7 +71,12 @@ class OmpHostToolHarness {
|
||||
arguments: Record<string, unknown>;
|
||||
}): Promise<OmpRpcHostToolResult> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<OmpRuntimeSession, OmpHostToolRouter>();
|
||||
|
||||
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<string[]> {
|
||||
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<string, unknown> {
|
||||
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<void> {
|
||||
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<string, PendingOmpHostToolCall>();
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
this.clear();
|
||||
await this.bridge?.close();
|
||||
}
|
||||
|
||||
waitForIdle(): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, BridgedTool>();
|
||||
private readonly clients: Client[] = [];
|
||||
private closed = false;
|
||||
|
||||
static async connect(
|
||||
servers: Record<string, McpServerConfig> | undefined,
|
||||
cwd: string,
|
||||
logger: Logger,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<OmpMcpBridge> {
|
||||
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<string, OmpBridgedToolIdentity> {
|
||||
return new Map(
|
||||
[...this.tools].map(([name, entry]) => [name, { server: entry.server, tool: entry.tool }]),
|
||||
);
|
||||
}
|
||||
|
||||
async execute(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
signal: AbortSignal,
|
||||
): Promise<PaseoToolResult> {
|
||||
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<void> {
|
||||
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<string, string>,
|
||||
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}`;
|
||||
}
|
||||
@@ -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<string, OmpTrackedToolCall>();
|
||||
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<string, OmpBridgedToolIdentity> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<OmpRuntimeEvent, { type: "extension_ui_request" }>;
|
||||
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<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface OmpRuntimeSession {
|
||||
setAutoCompaction(enabled: boolean): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
getState(): Promise<OmpSessionState>;
|
||||
setFastMode(enabled: boolean): Promise<{ enabled: boolean; active: boolean }>;
|
||||
getMessages(): Promise<OmpAgentMessage[]>;
|
||||
getAvailableModels(timeoutMs?: number | null): Promise<OmpModel[]>;
|
||||
setModel(provider: string, modelId: string): Promise<OmpModel>;
|
||||
@@ -65,7 +66,10 @@ export interface OmpRuntimeSession {
|
||||
branch(entryId: string): Promise<{ text: string }>;
|
||||
getBranchMessages(): Promise<Array<{ entryId: string; text: string }>>;
|
||||
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<void>;
|
||||
followUp(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
|
||||
@@ -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<string> {
|
||||
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 `<timestamp>_<id>` 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 })]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 <id>` supplies). Ids are resolved against the configured
|
||||
* sessions directory; an unresolvable handle yields `{}`.
|
||||
*/
|
||||
export async function readOmpImportSessionConfig(
|
||||
filePath: string,
|
||||
handle: string,
|
||||
options: OmpSessionDescriptorOptions = {},
|
||||
): Promise<OmpImportSessionConfig> {
|
||||
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<string | null> {
|
||||
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 `<timestamp>_<id>.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<boolean> {
|
||||
try {
|
||||
return (await stat(filePath)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function headerSessionIdMatches(filePath: string, sessionId: string): Promise<boolean> {
|
||||
const headChunk = await readHeadChunk(filePath);
|
||||
if (!headChunk) return false;
|
||||
return parseSessionHeaderFromChunk(headChunk)?.sessionId === sessionId;
|
||||
}
|
||||
|
||||
async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Promise<string> {
|
||||
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: {
|
||||
|
||||
@@ -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());
|
||||
@@ -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<string, string>();
|
||||
private readonly queuedSubagentSubscriptionErrors = new Map<
|
||||
FakeOmpSubagentSubscriptionLevel,
|
||||
Error
|
||||
@@ -64,12 +67,19 @@ export class FakeOmp implements OmpRuntime {
|
||||
}
|
||||
|
||||
async startSession(input: OmpStartSessionInput): Promise<FakeOmpSession> {
|
||||
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<void>; 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<void> {
|
||||
this.steerRequests.push({ message, imageCount: images?.length ?? 0 });
|
||||
if (this.steerError) throw this.steerError;
|
||||
}
|
||||
|
||||
followUp(
|
||||
|
||||
@@ -71,16 +71,18 @@ export class OmpHarness {
|
||||
providerIdleScheduler?: OmpProviderIdleScheduler;
|
||||
noTurnScheduler?: OmpNoTurnScheduler;
|
||||
usagePollScheduler?: OmpUsagePollScheduler;
|
||||
env?: Record<string, string>;
|
||||
providerIdleDeadlineMs?: number;
|
||||
runtimeEnv?: Record<string, string>;
|
||||
} = {},
|
||||
) {
|
||||
this.client = new OmpAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
runtimeSettings: { env: options.runtimeEnv },
|
||||
runtime: this.omp,
|
||||
runtimeSettings: options.env ? { env: options.env } : undefined,
|
||||
providerIdleScheduler: options.providerIdleScheduler,
|
||||
noTurnScheduler: options.noTurnScheduler,
|
||||
usagePollScheduler: options.usagePollScheduler,
|
||||
providerIdleDeadlineMs: options.providerIdleDeadlineMs,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,6 +155,20 @@ export class OmpHarness {
|
||||
return this.client.capabilities;
|
||||
}
|
||||
|
||||
persistence() {
|
||||
return this.requireSession().describePersistence();
|
||||
}
|
||||
|
||||
async replayHistory(handle: AgentPersistenceHandle): Promise<AgentStreamEvent[]> {
|
||||
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,
|
||||
@@ -453,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<void> {
|
||||
this.omp.latestSession().branchResponse = { text: restoredPrompt };
|
||||
await this.requireSession().revertConversation({ messageId });
|
||||
@@ -473,6 +519,15 @@ export class OmpHarness {
|
||||
await promptStarted;
|
||||
}
|
||||
|
||||
async startTurn(message: string): Promise<void> {
|
||||
await this.requireSession().startTurn(message);
|
||||
await waitForImmediate();
|
||||
}
|
||||
|
||||
startTurnDetached(message: string) {
|
||||
return this.requireSession().startTurn(message);
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
await this.requireSession().interrupt();
|
||||
}
|
||||
@@ -486,24 +541,7 @@ export class OmpHarness {
|
||||
}
|
||||
|
||||
async getUsageReference() {
|
||||
return this.client.resolveUsageReference({
|
||||
config: { provider: "omp", cwd: CWD },
|
||||
session: this.requireSession(),
|
||||
});
|
||||
}
|
||||
|
||||
async getStoredUsageReference(
|
||||
model: string,
|
||||
sessionId: string,
|
||||
configuredModel: string | null = model,
|
||||
) {
|
||||
const input = {
|
||||
config: { provider: "omp", cwd: CWD, model: configuredModel },
|
||||
runtimeInfo: { provider: "omp", sessionId, model },
|
||||
persistence: { provider: "omp", sessionId },
|
||||
session: null,
|
||||
};
|
||||
return this.client.resolveUsageReference(input);
|
||||
return this.requireSession().getUsageReference();
|
||||
}
|
||||
|
||||
runningToolCallIds(): string[] {
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -420,33 +420,19 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
},
|
||||
{ serverManager: runtime, createClient: runtime.createClient },
|
||||
);
|
||||
expect(
|
||||
await client.resolveUsageReference({
|
||||
config: { ...buildConfig(cwd), model: "openai/gpt-5" },
|
||||
session: null,
|
||||
}),
|
||||
).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "oauth-token", accountId: "acct-1" },
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
await session.setModel?.("openai/gpt-5");
|
||||
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "oauth-token", accountId: "acct-1" },
|
||||
});
|
||||
await session.setModel?.("opencode-go/qwen");
|
||||
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
source: "opencode-go",
|
||||
input: { apiKey: "go-key" },
|
||||
});
|
||||
await session.setModel?.("openai/gpt-5");
|
||||
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "oauth-token", accountId: "acct-1" },
|
||||
});
|
||||
await session.setModel?.("other/model");
|
||||
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toBeNull();
|
||||
expect(await session.getUsageReference?.()).toBeNull();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
const sessionId = (await session.getRuntimeInfo()).sessionId;
|
||||
@@ -462,7 +448,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
},
|
||||
} as OpenCodeEvent);
|
||||
await vi.waitFor(async () =>
|
||||
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "oauth-token", accountId: "acct-1" },
|
||||
}),
|
||||
@@ -482,46 +468,6 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("usage reference survives a model switch and default-model restart", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(logger, {
|
||||
env: {
|
||||
OPENCODE_AUTH_CONTENT: JSON.stringify({
|
||||
openai: { type: "oauth", access: "oauth-token", accountId: "acct-1" },
|
||||
"opencode-go": { type: "api", key: "go-key" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const runtimeInfo = {
|
||||
provider: "opencode",
|
||||
sessionId: "saved-session",
|
||||
model: "openai/gpt-5",
|
||||
};
|
||||
const closed = {
|
||||
config: { ...buildConfig(cwd), model: "openai/gpt-5" },
|
||||
runtimeInfo,
|
||||
persistence: null,
|
||||
session: null,
|
||||
};
|
||||
expect(await client.resolveUsageReference(closed)).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "oauth-token", accountId: "acct-1" },
|
||||
});
|
||||
expect(
|
||||
await client.resolveUsageReference({
|
||||
...closed,
|
||||
config: { ...buildConfig(cwd), model: null },
|
||||
}),
|
||||
).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "oauth-token", accountId: "acct-1" },
|
||||
});
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("usage reference ignores another auth entry with a different type", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
@@ -541,7 +487,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
try {
|
||||
await session.setModel?.("opencode-go/qwen");
|
||||
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
source: "opencode-go",
|
||||
input: { apiKey: "go-key" },
|
||||
});
|
||||
|
||||
@@ -1445,26 +1445,6 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
this.resolveHomeDir = deps.resolveHomeDir ?? resolveOpenCodeHomeDir;
|
||||
}
|
||||
|
||||
async resolveUsageReference({
|
||||
config,
|
||||
runtimeInfo,
|
||||
session,
|
||||
}: {
|
||||
config: AgentSessionConfig;
|
||||
runtimeInfo: AgentRuntimeInfo | null;
|
||||
session: AgentSession | null;
|
||||
}): Promise<UsageReference | null> {
|
||||
const model = session
|
||||
? (await session.getRuntimeInfo()).model
|
||||
: (config.model ?? runtimeInfo?.model);
|
||||
if (!model?.startsWith("openai/") && !model?.startsWith("opencode-go/")) return null;
|
||||
const env =
|
||||
session instanceof OpenCodeAgentSession
|
||||
? session.getUsageEnvironment()
|
||||
: { ...process.env, ...this.runtimeSettings?.env };
|
||||
return resolveOpenCodeUsageReference(model, await readOpenCodeUsageAuth(env));
|
||||
}
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
@@ -3640,8 +3620,11 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
getUsageEnvironment(): NodeJS.ProcessEnv {
|
||||
return this.usageEnv;
|
||||
async getUsageReference(): Promise<UsageReference | null> {
|
||||
const model = this.config.model;
|
||||
if (!model?.startsWith("openai/") && !model?.startsWith("opencode-go/")) return null;
|
||||
const auth = await readOpenCodeUsageAuth(this.usageEnv);
|
||||
return resolveOpenCodeUsageReference(model, auth);
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
|
||||
@@ -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<string, string> }> = [];
|
||||
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<OpenCodeEvent> {
|
||||
yield { id: "connected", created: 1, type: "server.connected", data: {} };
|
||||
const wake = () => this.notify?.();
|
||||
|
||||
@@ -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<void>((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<void>((resolve) => {
|
||||
readSnapshot = resolve;
|
||||
});
|
||||
const blocked = new Promise<void>((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<Error>((resolve) => {
|
||||
exit = resolve;
|
||||
@@ -185,7 +467,7 @@ describe("OpenCode v2 session lifecycle", () => {
|
||||
shutdown: async () => undefined,
|
||||
},
|
||||
});
|
||||
first.wait = () => new Promise<void>(() => 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<void>((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<void>((resolve) => {
|
||||
accept = resolve;
|
||||
});
|
||||
harness.wait = () =>
|
||||
new Promise<void>((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<void>((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<void>((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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user