Skip to content
Merged
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
124 changes: 124 additions & 0 deletions tests/web-model-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { afterEach, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
filterModelsByScopePatterns,
modelMatchesScopePattern,
readEnabledModelPatterns,
type ScopeFilterModel,
} from "../web/server/modelScope";

let directory: string | undefined;
afterEach(async () => {
if (directory) await rm(directory, { recursive: true, force: true });
directory = undefined;
});

const catalog: ScopeFilterModel[] = [
{ provider: "zai", id: "glm-5.3", name: "GLM 5.3" },
{ provider: "openai-codex", id: "gpt-5.6-luna", name: "Luna" },
{ provider: "opencode-go", id: "glm-5.2", name: "GLM 5.2 (opencode)" },
{ provider: "auto", id: "auto", name: "Auto (auto)" },
{ provider: "auto", id: "auto-low", name: "Auto (low)" },
{ provider: "auto", id: "auto-max", name: "Auto (max)" },
];

test("empty pattern list keeps every model", () => {
expect(filterModelsByScopePatterns(catalog, [])).toEqual(catalog);
});

test("exact provider/id patterns scope the list, case-insensitively", () => {
const scoped = filterModelsByScopePatterns(catalog, [
"zai/GLM-5.3",
"openai-codex/gpt-5.6-luna",
]);
expect(scoped.map((model) => `${model.provider}/${model.id}`)).toEqual([
"zai/glm-5.3",
"openai-codex/gpt-5.6-luna",
]);
});

test("auto/* glob keeps every Auto Router entry and excludes real providers", () => {
const scoped = filterModelsByScopePatterns(catalog, ["auto/*"]);
expect(scoped.map((model) => model.id)).toEqual([
"auto",
"auto-low",
"auto-max",
]);
});

test("globs match the bare id but never cross a slash", () => {
expect(
modelMatchesScopePattern("*luna*", "openai-codex", {
id: "gpt-5.6-luna",
}),
).toBe(true);
// `*` cannot cross the provider boundary in the full form, and the bare id
// has no slash to protect it either way.
expect(
modelMatchesScopePattern("openai*", "openai-codex", { id: "gpt-5.6-luna" }),
).toBe(false);
expect(
modelMatchesScopePattern("openai-codex/*", "openai-codex", {
id: "gpt-5.6-luna",
}),
).toBe(true);
});

test("thinking-level suffix is stripped before matching", () => {
expect(
modelMatchesScopePattern("zai/glm-5.3:high", "zai", { id: "glm-5.3" }),
).toBe(true);
// A colon that is not a thinking level stays part of the pattern.
expect(
modelMatchesScopePattern("zai/glm-5.3:not-a-level", "zai", {
id: "glm-5.3",
}),
).toBe(false);
});

test("non-glob patterns fall back to partial id and name containment", () => {
expect(
modelMatchesScopePattern("luna", "openai-codex", {
id: "gpt-5.6-luna",
name: "Luna",
}),
).toBe(true);
expect(
modelMatchesScopePattern("GLM 5", "zai", {
id: "glm-5.3",
name: "GLM 5.3",
}),
).toBe(true);
});

test("readEnabledModelPatterns reads enabledModels from a settings file", async () => {
directory = await mkdtemp(join(tmpdir(), "pi-model-scope-"));
const settingsPath = join(directory, "settings.json");
await writeFile(
settingsPath,
JSON.stringify({
theme: "dark",
enabledModels: ["zai/glm-5.3", "auto/*", 42, " ", null],
}),
);
expect(await readEnabledModelPatterns(settingsPath)).toEqual([
"zai/glm-5.3",
"auto/*",
]);
});

test("readEnabledModelPatterns returns no scope for missing, malformed, or unscoped settings", async () => {
directory = await mkdtemp(join(tmpdir(), "pi-model-scope-"));
const missing = join(directory, "missing.json");
expect(await readEnabledModelPatterns(missing)).toEqual([]);

const malformed = join(directory, "malformed.json");
await writeFile(malformed, "{not json");
expect(await readEnabledModelPatterns(malformed)).toEqual([]);

const unscoped = join(directory, "unscoped.json");
await writeFile(unscoped, JSON.stringify({ theme: "dark" }));
expect(await readEnabledModelPatterns(unscoped)).toEqual([]);
});
5 changes: 5 additions & 0 deletions tests/web-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2068,6 +2068,7 @@ for await (const line of lines) {
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
PI_WEB_RPC_BIN: fakePi,
PI_WEB_PORT: "0",
PI_WEB_ROOT: process.cwd(),
PI_WEB_STATE_FILE: statePath,
Expand Down Expand Up @@ -2711,6 +2712,7 @@ for await (const line of lines) {
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
PI_WEB_RPC_BIN: fakePi,
PI_WEB_PORT: "0",
PI_WEB_ROOT: process.cwd(),
PI_WEB_STATE_FILE: statePath,
Expand Down Expand Up @@ -2935,6 +2937,7 @@ test("managed RPC requests fail within the configured bound when Pi wedges", asy
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
PI_WEB_RPC_BIN: fakePi,
PI_WEB_PORT: "0",
PI_WEB_ROOT: process.cwd(),
PI_WEB_STATE_FILE: statePath,
Expand Down Expand Up @@ -3702,6 +3705,7 @@ for await (const line of lines) {
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
PI_WEB_RPC_BIN: fakePi,
PI_WEB_PORT: "0",
PI_WEB_ROOT: process.cwd(),
PI_WEB_STATE_FILE: statePath,
Expand Down Expand Up @@ -3783,6 +3787,7 @@ for await (const line of lines) {
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
PI_WEB_RPC_BIN: fakePi,
PI_WEB_PORT: "0",
PI_WEB_ROOT: process.cwd(),
PI_WEB_STATE_FILE: statePath,
Expand Down
2 changes: 1 addition & 1 deletion web/client/semantic-session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2712,7 +2712,7 @@ export function SemanticSession({
<div
ref={scrollRef}
data-testid="transcript-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain [overflow-anchor:none]"
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain [overflow-anchor:none]"
onWheel={(event) => {
scrollIntentRef.current = event.deltaY < 0 ? "up" : "down";
if (event.deltaY < 0) stopFollowing();
Expand Down
4 changes: 2 additions & 2 deletions web/client/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -741,8 +741,8 @@ details[open] > summary .semantic-tool-chevron {
padding-top: 0.65rem;
}
.semantic-edit-diff {
min-width: max-content;
overflow: hidden;
max-width: 100%;
overflow-x: auto;
border-radius: 0.4rem;
background: rgb(9 9 11 / 0.7);
}
Expand Down
46 changes: 26 additions & 20 deletions web/server/commandRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import {
isUncertainRpcDeliveryCommand,
} from "./managed-rpc-session.js";
import type { ManagedSessionRefresh } from "./managedSessionRefresh.js";
import {
filterModelsByScopePatterns,
readEnabledModelPatterns,
} from "./modelScope.js";
import type { RpcSessionFactory } from "./rpcSessions.js";
import type {
ExternalPendingRequest,
Expand Down Expand Up @@ -441,27 +445,29 @@ export function createCommandRouter(options: {
location: "temporary",
});
}
const scoped = record.scopedModels ?? [];
const scopedByKey = new Map(
scoped.map((s) => [`${s.provider}/${s.id}`, s]),
);
const filterByScope = scopedByKey.size > 0;
return {
models: models
.filter((model) =>
filterByScope
? scopedByKey.has(
`${String(model.provider ?? "")}/${String(model.id ?? "")}`,
)
: true,
// Managed sessions have no bridge to forward a resolved scope, so
// their scope is whatever `enabledModels` says in the shared
// settings file (the daemon spawns them without --models). Bridge
// sessions instead carry their resolved scope on the record.
const scopePatterns = record.scopedModels
? record.scopedModels.map(
(model) => `${model.provider}/${model.id}`,
)
.map((model) => ({
provider: String(model.provider ?? ""),
id: String(model.id ?? ""),
name: String(model.name ?? model.id ?? ""),
reasoning: model.reasoning === true,
thinkingLevels: levels,
})),
: await readEnabledModelPatterns(options.config.settingsPath);
const modelOptions = models.map((model) => ({
provider: String(model.provider ?? ""),
id: String(model.id ?? ""),
name: String(model.name ?? model.id ?? ""),
reasoning: model.reasoning === true,
}));
return {
models: filterModelsByScopePatterns(
modelOptions,
scopePatterns,
).map((model) => ({
...model,
thinkingLevels: levels,
})),
thinkingLevels: levels,
commands: webCommands,
};
Expand Down
42 changes: 40 additions & 2 deletions web/server/managed-rpc-session.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { RpcSessionCommand } from "../protocol.js";
import { SerializedWriter } from "./serialized-writer.js";

Expand Down Expand Up @@ -65,6 +66,43 @@ export function rpcDeliveryError(command: string, message: string): Error {
: new Error(message);
}

let cachedRpcCommand: string[] | undefined;

/**
* Command that starts a managed RPC Pi runtime.
*
* The daemon must spawn the exact `@earendil-works/pi-coding-agent` build it
* itself imports (session-file formats and RPC behavior move together), not
* whatever `pi` happens to be first on PATH — an upgraded global `pi` can
* otherwise reject the daemon's session files or load extensions
* differently, which silently dropped every package extension (including the
* Auto Router provider) from managed web sessions. `rpc-entry` hardcodes
* `--mode rpc`, so the mode flag is intentionally absent from the rest of
* the command. Running it under this daemon's own Bun binary keeps
* TypeScript extension loading identical to the daemon's. `PI_WEB_RPC_BIN`
* overrides the executable for tests and wrapper setups.
*/
function rpcSessionCommand(): string[] {
if (cachedRpcCommand) return cachedRpcCommand;
const override = process.env.PI_WEB_RPC_BIN?.trim();
let command: string[];
if (override) {
command = [override, "--mode", "rpc"];
} else {
try {
const entry = fileURLToPath(
import.meta.resolve("@earendil-works/pi-coding-agent/rpc-entry"),
);
command = [process.execPath, entry];
} catch {
// Fall back to PATH resolution when the package entry cannot be resolved.
command = ["pi", "--mode", "rpc"];
}
}
cachedRpcCommand = command;
return command;
}

export class ManagedRpcSession {
private readonly options: ManagedRpcSessionOptions;
private process: Bun.Subprocess | undefined;
Expand Down Expand Up @@ -121,11 +159,11 @@ export class ManagedRpcSession {
if (this.options.runtimeDirectory)
mkdirSync(this.options.runtimeDirectory, { recursive: true });
const env = { ...process.env, PI_WEB_MANAGED: "1" };
const args = ["--mode", "rpc"];
const args: string[] = [];
if (this.options.noSession) args.push("--no-session");
if (this.options.name) args.push("--name", this.options.name);
const proc = Bun.spawn({
cmd: ["pi", ...args],
cmd: [...rpcSessionCommand(), ...args],
cwd: this.options.cwd,
env,
stdin: "pipe",
Expand Down
Loading