Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/app/src/components/icons/OmpIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// omp (oh-my-pi) brand mark: the official Π glyph (top bar + two asymmetric
// stems), monochrome via `currentColor` so it themes consistently with the
// other provider icons. Distinct from vanilla pi's "Pi" wordmark by shape.
export function OmpIcon({ className }: { className?: string }) {
return (
<svg
fill="currentColor"
viewBox="0 0 64 64"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>oh-my-pi</title>
<path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
</svg>
);
}
60 changes: 45 additions & 15 deletions apps/app/src/components/pickers/ModelReasoningPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
Fragment,
useCallback,
useEffect,
useMemo,
Expand All @@ -10,7 +11,10 @@ import {
} from "react";
import type { SystemExecutionOptionsModelLoadError } from "@bb/server-contract";
import type { ReasoningLevel } from "@bb/domain";
import { stripModelBrandPrefix } from "./model-brand-prefix";
import {
groupModelOptionsByProvider,
stripModelBrandPrefix,
} from "./model-brand-prefix";
import { REASONING_LABELS } from "@/lib/reasoning-labels";
import { Button } from "@/components/ui/button.js";
import { Icon, type IconName } from "@/components/ui/icon.js";
Expand Down Expand Up @@ -311,6 +315,10 @@ export function ModelReasoningPicker({
activeModelLoadErrorMessage ?? "Could not load models.";
const activeModelOptions = previewModelOptions;
const activeMoreModelOptions = previewMoreModelOptions;
const modelGroups = useMemo(
() => groupModelOptionsByProvider(activeModelOptions),
[activeModelOptions],
);
const hasActiveModelOptions = activeModelOptions.length > 0;
const activeModelErrorIsProviderSpecific =
activeModelLoadErrorMatches && activeModelLoadError !== null;
Expand Down Expand Up @@ -559,7 +567,7 @@ export function ModelReasoningPicker({
"max-h-[min(250px,var(--radix-popover-content-available-height,250px)-80px)]",
)}
>
{isShowingModelError ? null : (
{isShowingModelError || modelGroups ? null : (
<MenuSectionLabel>Model</MenuSectionLabel>
)}
{activeModelIsLoading ? (
Expand All @@ -573,19 +581,41 @@ export function ModelReasoningPicker({
</div>
) : hasActiveModelOptions ? (
<>
{activeModelOptions.map((option) => (
<MenuRowButton
key={option.value}
// The menu always reflects the provider whose models it lists
// (either committed or previewed) — strip with `activeProviderId`.
label={stripModelBrandPrefix(
option.label,
activeProviderId,
)}
selected={!isPreviewing && option.value === modelValue}
onClick={() => handleModelSelect(option.value)}
/>
))}
{modelGroups
? modelGroups.map((group) => (
<Fragment key={group.providerKey}>
<MenuSectionLabel>
{group.providerLabel}
</MenuSectionLabel>
{group.options.map((option) => (
<MenuRowButton
key={option.value}
label={stripModelBrandPrefix(
option.label,
activeProviderId,
)}
selected={
!isPreviewing && option.value === modelValue
}
onClick={() => handleModelSelect(option.value)}
/>
))}
</Fragment>
))
: activeModelOptions.map((option) => (
<MenuRowButton
key={option.value}
// The menu always reflects the provider whose models it
// lists (either committed or previewed) — strip with
// `activeProviderId`.
label={stripModelBrandPrefix(
option.label,
activeProviderId,
)}
selected={!isPreviewing && option.value === modelValue}
onClick={() => handleModelSelect(option.value)}
/>
))}
{activeMoreModelOptions.length > 0 ? (
isCompactViewport ? (
<>
Expand Down
77 changes: 77 additions & 0 deletions apps/app/src/components/pickers/model-brand-prefix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,80 @@ export function stripModelBrandPrefix(
return label;
}
}

const PROVIDER_DISPLAY_LABELS: Record<string, string> = {
anthropic: "Anthropic",
cursor: "Cursor",
google: "Google",
openai: "OpenAI",
"openai-codex": "OpenAI Codex",
xai: "xAI",
"xai-oauth": "xAI",
zai: "Z.AI",
};

/**
* Extracts the provider segment from a `provider/modelId` model value, or null
* when the value has no provider prefix (codex/claude-code use bare ids).
*/
export function modelOptionProvider(value: string): string | null {
const slash = value.indexOf("/");
if (slash <= 0 || slash === value.length - 1) {
return null;
}
return value.slice(0, slash);
}

function providerDisplayLabel(provider: string): string {
return (
PROVIDER_DISPLAY_LABELS[provider] ??
provider
.replace(/-/g, " ")
.replace(/\b\w/g, (match) => match.toUpperCase())
);
}

export interface ModelOptionGroup<
TOption extends { value: string; label: string },
> {
providerKey: string;
providerLabel: string;
options: readonly TOption[];
}

/**
* Groups model options by their `provider/` prefix so providers like omp (whose
* catalog spans cursor / openai-codex / xai-oauth / zai) can surface a header
* per provider in the model dropdown. Returns null when options carry fewer
* than two distinct provider prefixes, so single-provider catalogs (codex,
* claude-code) keep rendering as a flat list with a single "Model" header.
*/
export function groupModelOptionsByProvider<
TOption extends { value: string; label: string },
>(options: readonly TOption[]): ModelOptionGroup<TOption>[] | null {
const providers = new Set<string>();
for (const option of options) {
const provider = modelOptionProvider(option.value);
if (provider) {
providers.add(provider);
}
}
if (providers.size < 2) {
return null;
}
const buckets = new Map<string, TOption[]>();
for (const option of options) {
const provider = modelOptionProvider(option.value) ?? "other";
const bucket = buckets.get(provider);
if (bucket) {
bucket.push(option);
} else {
buckets.set(provider, [option]);
}
}
return [...buckets.entries()].map(([provider, groupedOptions]) => ({
providerKey: provider,
providerLabel: providerDisplayLabel(provider),
options: groupedOptions,
}));
}
6 changes: 6 additions & 0 deletions apps/app/src/lib/provider-icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ClaudeIcon } from "@/components/icons/ClaudeIcon";
import { CursorIcon } from "@/components/icons/CursorIcon";
import { OpenAiIcon } from "@/components/icons/OpenAiIcon";
import { OpencodeIcon } from "@/components/icons/OpencodeIcon";
import { OmpIcon } from "@/components/icons/OmpIcon";
import { PiIcon } from "@/components/icons/PiIcon";
import { Icon } from "@/components/ui/icon";

Expand Down Expand Up @@ -72,6 +73,11 @@ export function getProviderIconInfo(
icon: PiIcon,
ariaLabel: providerInfo.displayName,
};
case "omp":
return {
icon: OmpIcon,
ariaLabel: providerInfo.displayName,
};
case "acp-cursor":
return {
icon: CursorIcon,
Expand Down
14 changes: 14 additions & 0 deletions apps/host-daemon/scripts/bundle-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ export const bundleTargets = [
label: "pi bridge",
outfile: resolve(packageRoot, "dist", "bb-pi-bridge.mjs"),
},
{
banner: NODE_ESM_REQUIRE_BANNER,
entryPoint: resolve(
workspaceRoot,
"packages",
"agent-runtime",
"src",
"omp",
"bridge",
"bridge.ts",
),
label: "omp bridge",
outfile: resolve(packageRoot, "dist", "bb-omp-bridge.mjs"),
},
{
banner: NODE_ESM_REQUIRE_BANNER,
entryPoint: resolve(
Expand Down
37 changes: 37 additions & 0 deletions apps/host-daemon/src/runtime-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ interface RuntimeManagerProviderMaintenanceInternals {
providerMaintenanceRuntime: AgentRuntime | null;
}

interface RuntimeManagerEvictionInternals {
stopWatchingStatus: (entry: unknown) => Promise<void>;
}

const execFileAsync = promisify(execFile);
const tempDirs: string[] = [];

Expand Down Expand Up @@ -1200,6 +1204,39 @@ describe("RuntimeManager", () => {
expect(secondRuntime.shutdown).not.toHaveBeenCalled();
});

it("does not evict environment runtimes that become active during base shell env eviction", async () => {
const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1");
const runtime = createFakeRuntime();
const manager = new RuntimeManager({
provisionWorkspace,
createRuntime: vi.fn(() => runtime),
shellEnv: {
PATH: "/old/bin:/usr/bin",
},
});

await manager.ensureEnvironment({
environmentId: "env-1",
workspacePath: "/tmp/env-1",
});
const managerInternals = manager as unknown as RuntimeManagerEvictionInternals;
const originalStopWatchingStatus =
managerInternals.stopWatchingStatus.bind(manager);
vi.spyOn(managerInternals, "stopWatchingStatus").mockImplementation(
async (entry) => {
runtime.setActiveTurn("thread-1", "turn-1");
await originalStopWatchingStatus(entry);
},
);

await manager.replaceBaseShellEnv({
PATH: "/new/bin:/usr/bin",
});

expect(manager.get("env-1")).toBeDefined();
expect(runtime.shutdown).not.toHaveBeenCalled();
});

it("reuses the existing runtime for subsequent requests", async () => {
const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1");
const createRuntime = vi.fn(() => createFakeRuntime());
Expand Down
9 changes: 7 additions & 2 deletions apps/host-daemon/src/runtime-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,13 +545,18 @@ export class RuntimeManager {
}

private async evictIdleRuntimeEntries(): Promise<void> {
const idleEntries = [...this.entries.values()].filter(
const candidateEntries = [...this.entries.values()].filter(
(entry) => !this.entryHasActiveRuntimeWork(entry),
);
const idleEntries: RuntimeEntry[] = [];

for (const entry of idleEntries) {
for (const entry of candidateEntries) {
await this.stopWatchingStatus(entry);
if (this.entryHasActiveRuntimeWork(entry)) {
continue;
}
this.entries.delete(entry.environmentId);
idleEntries.push(entry);
}

await Promise.all(idleEntries.map((entry) => entry.runtime.shutdown()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ describe("providerHasCommandSurface", () => {
it("derives command typeahead support from provider-declared skills actions", () => {
expect(providerHasCommandSurface("codex")).toBe(true);
expect(providerHasCommandSurface("claude-code")).toBe(true);
expect(providerHasCommandSurface("pi")).toBe(false);
expect(providerHasCommandSurface("omp")).toBe(false);
expect(providerHasCommandSurface("unknown-provider")).toBe(false);
});
});
42 changes: 42 additions & 0 deletions apps/server/test/public/public-thread-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1847,6 +1847,48 @@ describe("public thread data routes", () => {
});
});

it("returns null default execution options for stale stored omp capabilities", async () => {
await withTestHarness(async (harness) => {
const { environment, thread } = seedThreadFixture(harness, {
thread: { providerId: "omp" },
});
seedEvent(harness.deps, {
threadId: thread.id,
environmentId: environment.id,
sequence: 1,
type: "client/turn/requested",
scope: threadScope(),
data: {
direction: "outbound",
requestId: encodeClientTurnRequestIdNumber({ value: 205 }),
input: [{ type: "text", text: "Prior request" }],
target: { kind: "new-turn" },
execution: {
model: "openai/codex-mini",
reasoningLevel: "medium",
permissionMode: "workspace-write",
serviceTier: "default",
source: "client/turn/requested",
},
initiator: "user",
senderThreadId: null,
request: {
method: "turn/start",
params: {},
},
source: "tell",
},
});

const response = await harness.app.request(
`/api/v1/threads/${thread.id}/default-execution-options`,
);

expect(response.status).toBe(200);
await expect(readJson(response)).resolves.toBeNull();
});
});

it("fails loudly when the latest stored request event is malformed", async () => {
await withTestHarness(async (harness) => {
const { environment, thread } = seedThreadFixture(harness);
Expand Down
Loading
Loading