From 4c83ae247845bebcd9fa2aa8e5b4bc7861a87e6a Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:31:04 -0400 Subject: [PATCH] Fix web model picker scope, Auto Router entries, and transcript overflow Three managed-session defects, each verified end to end against a live daemon: Model picker ignored enabledModels for managed sessions. The managed get_session_options path filtered by record.scopedModels, which only TUI bridges ever populate (agent.hello / agent.scope), so web-created sessions listed every authenticated model. Managed runtimes are spawned without --models, so their scope is exactly the enabledModels patterns in the shared settings file; the daemon now resolves those patterns itself (web/server/modelScope.ts) with Pi's matching semantics: exact provider/id or bare id, partial id/name fallback, minimatch-style globs that never cross a slash, and an optional :thinking suffix. Bridge records keep their forwarded resolved scope. Auto Router entries never appeared because managed children silently lost every package extension: the daemon spawned PATH-resolved pi (global 0.84.2) while importing pi-coding-agent 0.84.1 itself, and the version skew made the child drop the whole pi-kit package after the startup switch_session. Managed runtimes now spawn the daemon's own pinned @earendil-works/pi-coding-agent/rpc-entry under the daemon's Bun binary (PI_WEB_RPC_BIN overrides for tests and wrappers). The transcript grew a horizontal scrollbar: .semantic-edit-diff sized itself to min-content with no clipping ancestor, so wide diff rows pushed the whole column. It now scrolls internally (max-width: 100%, overflow-x: auto) and the transcript scroller clips overflow-x as a backstop, matching how pre/table already self-scroll. --- tests/web-model-scope.test.ts | 124 ++++++++++++++++++++++++++++++ tests/web-server.test.ts | 5 ++ web/client/semantic-session.tsx | 2 +- web/client/styles.css | 4 +- web/server/commandRouter.ts | 46 ++++++----- web/server/managed-rpc-session.ts | 42 +++++++++- web/server/modelScope.ts | 123 +++++++++++++++++++++++++++++ 7 files changed, 321 insertions(+), 25 deletions(-) create mode 100644 tests/web-model-scope.test.ts create mode 100644 web/server/modelScope.ts diff --git a/tests/web-model-scope.test.ts b/tests/web-model-scope.test.ts new file mode 100644 index 0000000..0f819c5 --- /dev/null +++ b/tests/web-model-scope.test.ts @@ -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([]); +}); diff --git a/tests/web-server.test.ts b/tests/web-server.test.ts index 5d8f7b1..fe5247f 100644 --- a/tests/web-server.test.ts +++ b/tests/web-server.test.ts @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index 9f7dc11..4e06d77 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -2712,7 +2712,7 @@ export function SemanticSession({
{ scrollIntentRef.current = event.deltaY < 0 ? "up" : "down"; if (event.deltaY < 0) stopFollowing(); diff --git a/web/client/styles.css b/web/client/styles.css index 1fad1d3..7609516 100644 --- a/web/client/styles.css +++ b/web/client/styles.css @@ -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); } diff --git a/web/server/commandRouter.ts b/web/server/commandRouter.ts index b8657a9..7d9ae0c 100644 --- a/web/server/commandRouter.ts +++ b/web/server/commandRouter.ts @@ -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, @@ -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, }; diff --git a/web/server/managed-rpc-session.ts b/web/server/managed-rpc-session.ts index eed998b..b0d7145 100644 --- a/web/server/managed-rpc-session.ts +++ b/web/server/managed-rpc-session.ts @@ -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"; @@ -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; @@ -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", diff --git a/web/server/modelScope.ts b/web/server/modelScope.ts new file mode 100644 index 0000000..03f4c90 --- /dev/null +++ b/web/server/modelScope.ts @@ -0,0 +1,123 @@ +import { readFile } from "node:fs/promises"; + +/** + * Model-scope filtering for managed web sessions. + * + * Pi's `/model` picker defaults to the session's `scopedModels` — resolved from + * `--models` or, absent that flag, the global `enabledModels` setting. TUI + * bridge sessions forward their resolved scope to the daemon via + * `agent.hello`/`agent.scope`, so `record.scopedModels` already mirrors the + * picker there. Managed RPC sessions have no such channel: the RPC protocol + * exposes no scope query, and the daemon spawns them without `--models`, so + * their scope is exactly the `enabledModels` patterns in the same settings + * file the daemon already reads. Re-resolving those patterns here keeps the + * web picker in sync with what the TUI would show. + */ + +const THINKING_LEVEL_SUFFIXES = new Set([ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** Read `enabledModels` from a Pi settings file. Unreadable/absent/malformed means no scoping. */ +export async function readEnabledModelPatterns( + settingsPath: string, +): Promise { + try { + const root: unknown = JSON.parse(await readFile(settingsPath, "utf8")); + if (!isRecord(root) || !Array.isArray(root.enabledModels)) return []; + return root.enabledModels.filter( + (pattern): pattern is string => + typeof pattern === "string" && pattern.trim().length > 0, + ); + } catch { + return []; + } +} + +/** + * Convert a glob to a RegExp with minimatch's default semantics for the + * characters we support: `*` and `?` never cross `/`, and `[...]` character + * classes pass through. Everything else is literal. + */ +function globToRegExp(pattern: string): RegExp { + let source = ""; + for (let index = 0; index < pattern.length; index++) { + const char = pattern[index]; + if (char === "*") source += "[^/]*"; + else if (char === "?") source += "[^/]"; + else if (char === "\\") source += "\\\\"; + else source += char.replace(/[.+^${}()|[\]]/g, "\\$&"); + } + return new RegExp(`^${source}$`, "i"); +} + +function hasGlobCharacter(pattern: string): boolean { + return /[*?[]/.test(pattern); +} + +/** Strip an optional `:` suffix (e.g. `zai/glm-5.3:high`), as Pi's scope resolver does. */ +function withoutThinkingSuffix(pattern: string): string { + const colonIndex = pattern.lastIndexOf(":"); + if (colonIndex < 0) return pattern; + const suffix = pattern.slice(colonIndex + 1); + return THINKING_LEVEL_SUFFIXES.has(suffix) + ? pattern.slice(0, colonIndex) + : pattern; +} + +/** + * Whether `provider`/`id` matches one scope pattern, following the same rules + * Pi applies to `enabledModels`: exact `provider/id` or bare-id equality + * (case-insensitive), a partial id/name containment fallback, or — when the + * pattern contains glob characters — minimatch-style matching against the + * full `provider/id` form or the bare id. The Auto Router's `auto/*` pattern + * relies on the glob path. + */ +export function modelMatchesScopePattern( + pattern: string, + provider: string, + model: { id: string; name?: string }, +): boolean { + const bare = withoutThinkingSuffix(pattern.trim()); + if (bare.length === 0) return false; + const fullId = `${provider}/${model.id}`; + if (hasGlobCharacter(bare)) { + const expression = globToRegExp(bare); + return expression.test(fullId) || expression.test(model.id); + } + const lowered = bare.toLowerCase(); + if (lowered === fullId.toLowerCase() || lowered === model.id.toLowerCase()) + return true; + return ( + model.id.toLowerCase().includes(lowered) || + (model.name ? model.name.toLowerCase().includes(lowered) : false) + ); +} + +export type ScopeFilterModel = { provider: string; id: string; name?: string }; + +/** + * Keep only the models allowed by `patterns`. An empty pattern list means no + * scoping is configured and every model passes through, matching Pi's picker. + */ +export function filterModelsByScopePatterns( + models: Model[], + patterns: readonly string[], +): Model[] { + if (patterns.length === 0) return models; + return models.filter((model) => + patterns.some((pattern) => + modelMatchesScopePattern(pattern, model.provider, model), + ), + ); +}