diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d94020..ca226e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- `omp Agent Directory`: Resolve the agent directory from the host's own `getAgentDir()` (registered by the composition root via `setHostAgentDirResolver`) instead of sniffing `execPath`/`argv[1]` for an `omp` prefix. omp installed through Bun runs as `bun …/@oh-my-pi/pi-coding-agent/dist/cli.js`, which matched neither, so with `telegram.json` in `~/.omp/agent` the bridge never connected and wrote its log and bus socket under `~/.pi/agent`. `PI_CODING_AGENT_DIR` still wins; with no host registered the fallback stays `~/.pi/agent`. Impact: a bot token saved by `/telegram-setup` lives in omp's own agent directory and is found again. `lib/paths.ts` stays free of Pi SDK imports, which cost about a second of startup per importing process. +- `omp Settled Boundary`: omp never emits `agent_settled`, the only place the activity runtime finalized a turn. On omp the first terminal `agent_end` (no `willContinue: true`, which omp sets for retries, compaction continuations, todo/plan reminders and async wakes) now drives `onAgentSettled`; hosts that emit `agent_settled` natively are unchanged. Impact: Activity API consumers receive `agent-settled` and the activity id no longer leaks across turns. +- `omp Current Model`: omp never emits `model_select`, so the stored current model went stale after a local `/model` switch, and omp builds command contexts by spreading a base context, which freezes `ctx.model` at the moment of `/telegram-connect`. On omp the live `ctx.models.current()` now wins over both, and the stored value no longer overrides it. Impact: the Telegram model menu marks the model omp is actually using. +- `omp Settings Manager`: omp's `SettingsManager.create()` returns its own `Settings`, which has no `reload()` or `getEnabledModels()`; rendering the model menu and `/status` failed with `settingsManager.reload is not a function`, so no menu reached Telegram. The SDK boundary now adapts it (`reloadFromDisk()`, global `enabledModels` read) and scoped-model persistence is not offered on omp. Impact: the operator menus render on omp. - `Time Injection`: Reject non-finite configured intervals and resume time annotations after the wall clock moves backwards. - `Paths`: Resolve diagnostic paths from the current agent directory and profile instead of reporting hard-coded `~/.pi` locations. - `Security`: Apply the `brace-expansion` `5.0.12` override at both root and coding-agent scopes to address the latest recursion and expansion denial-of-service advisories. diff --git a/README.md b/README.md index 50eafe4..4cdc1bb 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Telegram DM ──▶ getUpdates (single polling owner) - Node.js `>= 22.19.0` (declared in `engines`; verified here on 24.20.0) - npm `>= 10` for the validation gates (verified on 11.19.0). `bun` also works for running the extension, but the gates are npm-based. -- `omp` `>= 16.5.2` (verified with `omp` 18.1.20) or a Pi runtime exposing the same `@earendil-works/pi-*` extension API +- `omp` `>= 16.5.2` (verified with `omp` 18.4.8) or a Pi runtime exposing the same `@earendil-works/pi-*` extension API - A Telegram bot token from [@BotFather](https://t.me/BotFather) ## Quick start @@ -100,7 +100,7 @@ Then inside `omp`: Finally, open the bot DM and send `/start`. The first Telegram user to write becomes the allowed owner. -Config is written to `/telegram.json`. The agent directory is resolved as: `PI_CODING_AGENT_DIR` if set, then `~/.omp/agent` when the runtime is `omp`, otherwise `~/.pi/agent`. +Config is written to `/telegram.json`. The agent directory is `PI_CODING_AGENT_DIR` when set, otherwise the directory the host reports through `getAgentDir()`: `~/.pi/agent` on Pi, and on `omp` its profile-aware directory (`~/.omp/agent` by default). The executable name and `argv` are not inspected. ## Usage @@ -217,6 +217,7 @@ Everything below was executed in this repository on Node 24.20.0 / npm 11.19.0 ( | `omp plugin doctor` | `3 ok, 1 warnings, 0 errors` (warning: no plugin package manifest) | | `omp install ` | exit 0, `Linked @evandrodevbr/oh-my-pi-telegram from .` (the link was reverted afterwards to leave the machine as it was) | | `omp install --link ` | fails: `Unknown option '--link'` (kept out of this README) | +| ad hoc e2e on `omp` 18.4.8 | throwaway harness, not kept in the repo: real `omp --mode rpc` with a sandboxed `HOME`, a fake Telegram Bot API and a fake OpenAI-compatible model. `/telegram-connect` finds `~/.omp/agent/telegram.json` and nothing is written under `~/.pi`; a Telegram prompt runs a model turn and the reply is delivered; Activity API handlers receive `agent-settled`; `/model` renders and marks the model that was switched locally in `omp` | The test suite is deterministic and offline: transport calls are injected, so no live Telegram bot is required. Live Telegram paths (long polling, Rich Messages, Threaded Mode threading) are covered by the operator's own smoke sessions, not by this suite; the `Validate` workflow (npm ci + `npm run validate` on Node 24) is green on `main` (run 34838605928, 2026-09-14). @@ -224,11 +225,12 @@ The test suite is deterministic and offline: transport calls are injected, so no - **Not published to npm**: `@evandrodevbr/oh-my-pi-telegram` returns 404 on the registry. Install from a local checkout; on top of that, `omp` 18.1.20's plugin source resolver rejects `npm` sources (`npm plugin sources are not yet supported. Use git-based sources instead.`). - **No GitHub release yet**: the only tag, `v0.22.0-evandro.1`, produced a failed `Release` run (`CHANGELOG.md has no section for 0.22.0-evandro.1`, because the fork section was a level-3 heading). The heading is fixed in this commit, but the existing tag still points at the pre-fix commit, so the release must be created manually or the tag moved. -- **Fork lags upstream**: this fork stays on upstream `0.22.0` (SHA `afe09c5`), while `@llblab/pi-telegram` has since published 0.46.0. Upstream changes after 0.22.0 are not included. +- **Fork lags upstream**: this fork stays on upstream `0.22.0` (SHA `afe09c5`), while `@llblab/pi-telegram` has since published 0.51.6. Upstream changes after 0.22.0 are not included. Upstream 0.51.6 loads in `omp` 18.4.8 but subscribes to five events `omp` never emits (`agent_settled`, `model_select`, `session_compact_failed`, `ui_prompt_start`, `ui_prompt_end`), so it is not a drop-in replacement. - **Verified on one platform only**: Linux with Node 24.20.0. The suite contains Windows-specific cases (and the single skipped test is one of them), but Windows/macOS were not run here. -- **Requires a live `omp` instance and a real bot token** for end-to-end use; no automated test covers the Telegram API surface itself. +- **Requires a live `omp` instance and a real bot token** for end-to-end use; the repository suite does not cover the Telegram API surface, and the `omp` 18.4.8 checks above used a fake Bot API, so live Telegram behavior (Rich Messages, Threaded Mode, voice, files) is unverified on `omp`. - **LLM/token cost applies**: a Telegram prompt is a normal model turn in the active session, so it inherits that session's post-compaction context. - **Single-owner by design**: the first Telegram user to message the bot is the owner; other users are ignored. No multi-user or group-chat support. +- **omp host differences**: `omp` emits neither `agent_settled` nor `model_select`. The bridge derives the settled boundary from the first terminal `agent_end` (`willContinue` not `true`) and reads the live model through `ctx.models.current()`. Scoped-model persistence from the Telegram model menu is not offered on `omp`, and its scoped list is read only from a global `enabledModels` array. - `bun.lock` is still present from upstream while CI validates with `package-lock.json`; the two are not kept in sync. ## Documentation diff --git a/index.ts b/index.ts index cfc710e..2fa90b1 100644 --- a/index.ts +++ b/index.ts @@ -51,6 +51,7 @@ type ActivePiModel = NonNullable; // --- Extension Runtime --- export default function (pi: Pi.ExtensionAPI) { + Paths.setHostAgentDirResolver(Pi.getHostAgentDir); const piRuntime = Pi.createExtensionApiRuntimePorts(pi); const { getCommands, @@ -397,6 +398,7 @@ export default function (pi: Pi.ExtensionAPI) { const currentModelRuntime = Model.createCurrentModelRuntime({ getContextModel, updateStatus, + preferContextModel: Pi.isOmpHost(pi), }); const queueMutationRuntime = Queue.createTelegramQueueMutationController({ ...telegramQueueStore, @@ -698,10 +700,12 @@ export default function (pi: Pi.ExtensionAPI) { resolveTimeLine: timeInjectionRuntime.resolveLine, getThinkingLevel, setThinkingLevel, - persistScopedModelPatterns: Pi.createScopedModelPatternPersister({ - createSettingsManager: Pi.createSettingsManager, - clearCachedModelMenuInputs: modelMenuRuntime.clearCachedInputs, - }), + persistScopedModelPatterns: Pi.isOmpHost(pi) + ? undefined + : Pi.createScopedModelPatternPersister({ + createSettingsManager: Pi.createSettingsManager, + clearCachedModelMenuInputs: modelMenuRuntime.clearCachedInputs, + }), setModel, sendUserMessage, isIdle, diff --git a/lib/bindings.ts b/lib/bindings.ts index 968e237..5dd0194 100644 --- a/lib/bindings.ts +++ b/lib/bindings.ts @@ -639,6 +639,7 @@ export function registerTelegramLifecycleRuntimeHooks({ if (!isSessionContextActive(ctx)) return; activityRuntime.onAgentSettled(); }, + settleOnTerminalAgentEnd: Pi.isOmpHost(pi), onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({ isConfigured: configStore.hasBotToken, isProactivePushEnabled, diff --git a/lib/lifecycle.ts b/lib/lifecycle.ts index 8090a7c..2054c63 100644 --- a/lib/lifecycle.ts +++ b/lib/lifecycle.ts @@ -23,6 +23,7 @@ import type { ToolExecutionStartEvent, ToolExecutionUpdateEvent, } from "./pi.ts"; +import { isTerminalAgentEnd } from "./pi.ts"; let resetTransportReplyDedupFn: (() => void) | undefined; @@ -110,6 +111,12 @@ export interface TelegramLifecycleRegistrationDeps { event: AgentSettledEvent, ctx: ExtensionContext, ) => Promise | void; + /** + * Hosts that never emit `agent_settled` (omp) get it synthesized from the + * first terminal `agent_end`. Leave unset on hosts that emit it natively so + * the settle point is not announced early. + */ + settleOnTerminalAgentEnd?: boolean; } export interface TelegramSessionLifecycleHooks { @@ -562,6 +569,9 @@ export function registerTelegramLifecycleHooks( pi.on("agent_end", async (event, ctx) => { if (!isActive(ctx)) return; await deps.onAgentEnd(event, ctx); + if (deps.settleOnTerminalAgentEnd && isTerminalAgentEnd(event)) { + await deps.onAgentSettled?.({ type: "agent_settled" }, ctx); + } }); pi.on("agent_settled", async (event, ctx) => { if (!isActive(ctx)) return; diff --git a/lib/model.ts b/lib/model.ts index 6dc9efe..b4a62f7 100644 --- a/lib/model.ts +++ b/lib/model.ts @@ -55,15 +55,25 @@ export type CurrentModelRuntime< > = CurrentModelStore & CurrentModelUpdateRuntime; +/** + * `preferContextModel` makes the host's live context model win over the stored + * one. Pi pushes model changes through `model_select`, so its stored value is + * current; omp has no such event, so its stored value would go stale after a + * local `/model` switch. + */ export function createCurrentModelStore< TContext, TModel extends MenuModel = MenuModel, >( getContextModel: (ctx: TContext) => TModel | undefined, + preferContextModel = false, ): CurrentModelStore { let currentModel: TModel | undefined; return { - get: (ctx) => currentModel ?? getContextModel(ctx), + get: (ctx) => + preferContextModel + ? (getContextModel(ctx) ?? currentModel) + : (currentModel ?? getContextModel(ctx)), getStored: () => currentModel, set: (model) => { currentModel = model; @@ -96,8 +106,12 @@ export function createCurrentModelRuntime< >(deps: { getContextModel: (ctx: TContext) => TModel | undefined; updateStatus: (ctx: TContext) => void; + preferContextModel?: boolean; }): CurrentModelRuntime { - const store = createCurrentModelStore(deps.getContextModel); + const store = createCurrentModelStore( + deps.getContextModel, + deps.preferContextModel, + ); return { ...store, ...createCurrentModelUpdateRuntime({ diff --git a/lib/paths.ts b/lib/paths.ts index 1186d0f..ecc00a2 100644 --- a/lib/paths.ts +++ b/lib/paths.ts @@ -1,19 +1,32 @@ /** * Telegram bridge path resolution for Pi-compatible runtimes * Zones: telemetry paths, filesystem, runtime identity - * Owns agent-dir detection and extension-local path derivation + * Owns agent-dir resolution and extension-local path derivation * - * This domain is pure/path-only: it resolves directories and file paths - * from environment and runtime identity. It does not read config, manage - * state, or import broader Telegram domains. + * This domain is pure/path-only: it resolves directories and file paths from + * the environment and the host runtime's own agent directory. It does not read + * config, manage state, or import broader Telegram domains or the Pi SDK. */ import { homedir } from "node:os"; import { join, resolve } from "node:path"; export interface TelegramAgentDirResolutionInput { env?: Partial>; - execPath?: string; - argv?: readonly string[]; +} + +let hostAgentDirResolver: (() => string) | undefined; + +/** + * Register the host runtime's own agent-directory lookup (the Pi SDK + * `getAgentDir()`). It is registered by the composition root instead of + * imported here: importing the Pi SDK from this leaf made every domain that + * touches a path, and every child process the test suite spawns, pay about a + * second of SDK startup. + */ +export function setHostAgentDirResolver( + resolver: (() => string) | undefined, +): void { + hostAgentDirResolver = resolver; } /** @@ -21,23 +34,18 @@ export interface TelegramAgentDirResolutionInput { * * Precedence: * 1. `PI_CODING_AGENT_DIR` env variable, when explicitly set. - * 2. Detect Pi-compatible runtime identity from the executable or argv[1] - * (e.g. OMP vs standard Pi agent). - * 3. Fallback: `~/.pi/agent`. + * 2. The registered host agent directory. Pi returns `~/.pi/agent`; omp's Pi + * shim returns its profile-aware directory (default `~/.omp/agent`). The + * executable and argv are not inspected: omp installed through Bun runs as + * `bun .../dist/cli.js`, which neither contains nor starts with `omp`. + * 3. Fallback when no host is registered: `~/.pi/agent`. */ export function resolveAgentDir( input: TelegramAgentDirResolutionInput = {}, ): string { const env = input.env ?? process.env; if (env.PI_CODING_AGENT_DIR) return resolve(env.PI_CODING_AGENT_DIR); - const execPath = input.execPath ?? process.execPath; - const argv = input.argv ?? process.argv; - const execBasename = execPath.toLowerCase().split(/[\\/]/u).pop() ?? ""; - const argv1Last = (argv[1] ?? "").toLowerCase().split(/[\\/]/u).pop() ?? ""; - if (execBasename.startsWith("omp") || argv1Last.startsWith("omp")) { - return join(homedir(), ".omp", "agent"); - } - return join(homedir(), ".pi", "agent"); + return hostAgentDirResolver?.() ?? join(homedir(), ".pi", "agent"); } /** Telegram bridge configuration file (/telegram.json). */ diff --git a/lib/pi.ts b/lib/pi.ts index 871dd73..b8fa355 100644 --- a/lib/pi.ts +++ b/lib/pi.ts @@ -20,6 +20,7 @@ import { type SessionStartEvent, type SlashCommandInfo, SettingsManager, + getAgentDir, } from "@earendil-works/pi-coding-agent"; export type { @@ -39,6 +40,13 @@ export type { SlashCommandInfo, }; +/** + * Agent data directory owned by the host runtime. Pi returns its own root and + * omp's legacy Pi shim forwards omp's profile-aware directory, so no argv or + * executable sniffing is needed. + */ +export { getAgentDir as getHostAgentDir }; + export interface ToolExecutionStartEvent { type: "tool_execution_start"; toolCallId: string; @@ -69,6 +77,26 @@ export interface PiSettingsManager { setEnabledModels: (patterns: string[] | undefined) => void; } +/** + * omp's `Settings` is what its legacy shim returns from + * `SettingsManager.create()`. It has no `reload()` or `get/setEnabledModels()`: + * it reloads through `reloadFromDisk()` and keeps `enabledModels` as a + * path-scoped typed setting. + */ +interface OmpSettingsSurface { + reloadFromDisk: () => Promise; + flush: () => Promise; + getGlobalSettings: () => { enabledModels?: unknown }; +} + +function isOmpSettings(value: object): value is OmpSettingsSurface { + return ( + "reloadFromDisk" in value && + "flush" in value && + "getGlobalSettings" in value + ); +} + export type PiSlashCommandInfo = SlashCommandInfo; export type PiRunMode = "tui" | "rpc" | "json" | "print"; @@ -150,7 +178,22 @@ export function createExtensionApiRuntimePorts( } export function createSettingsManager(cwd: string): PiSettingsManager { - return SettingsManager.create(cwd); + const manager: PiSettingsManager = SettingsManager.create(cwd); + if (!isOmpSettings(manager)) return manager; + return { + reload: () => manager.reloadFromDisk(), + flush: () => manager.flush(), + getEnabledModels: () => { + const enabled = manager.getGlobalSettings().enabledModels; + return Array.isArray(enabled) && + enabled.every((pattern) => typeof pattern === "string") + ? enabled + : undefined; + }, + setEnabledModels: () => { + throw new Error("Scoped model persistence is not supported on omp."); + }, + }; } export function createScopedModelPatternPersister(deps: { @@ -167,16 +210,56 @@ export function createScopedModelPatternPersister(deps: { }; } +interface OmpModelQuery { + current: () => ExtensionContext["model"]; +} + +function hasOmpModelQuery( + ctx: ExtensionContext, +): ctx is ExtensionContext & { models: OmpModelQuery } { + return ( + "models" in ctx && + typeof ctx.models === "object" && + ctx.models !== null && + "current" in ctx.models && + typeof ctx.models.current === "function" + ); +} + +/** + * omp builds command contexts by spreading a base context, which evaluates the + * `model` getter once: a context stored from `/telegram-connect` would report + * the model of that moment forever. omp's `ctx.models.current()` closes over + * the live session model, so prefer it when present. + */ export function getExtensionContextModel( ctx: ExtensionContext, ): ExtensionContext["model"] { - return ctx.model; + return hasOmpModelQuery(ctx) ? ctx.models.current() : ctx.model; } export function getExtensionContextCwd(ctx: ExtensionContext): string { return ctx.cwd; } +/** + * True when the extension API belongs to omp. omp's `ExtensionAPI` carries + * `typebox` and `pi` module facades that upstream Pi's API does not have. + */ +export function isOmpHost(api: ExtensionAPI): boolean { + return "typebox" in api && "pi" in api; +} + +/** + * omp marks an `agent_end` with `willContinue: true` whenever it already + * scheduled a retry, compaction continuation, todo/plan reminder, or async + * wake. Any other `agent_end` is terminal, which is what Pi reports through + * `agent_settled`. + */ +export function isTerminalAgentEnd(event: AgentEndEvent): boolean { + return !("willContinue" in event && event.willContinue === true); +} + export function isExtensionContextIdle(ctx: ExtensionContext): boolean { return ctx.isIdle(); } diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index c1d820a..ec686d4 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -560,3 +560,38 @@ test("Lifecycle helpers register pi hooks and delegate to handlers", async () => "agent-end", ]); }); + +test("Terminal agent_end synthesizes agent_settled only for hosts that never emit it", async () => { + const cases = [ + { settle: true, event: { messages: [] }, expected: ["agent-end", "agent-settled"] }, + { settle: true, event: { messages: [], willContinue: true }, expected: ["agent-end"] }, + { settle: false, event: { messages: [] }, expected: ["agent-end"] }, + ]; + for (const { settle, event, expected } of cases) { + const harness = createLifecycleApiHarness(); + const events: string[] = []; + registerTelegramLifecycleHooks(harness.api, { + settleOnTerminalAgentEnd: settle, + onSessionStart: async () => {}, + onSessionShutdown: async () => {}, + onBeforeAgentStart: () => undefined, + onModelSelect: () => {}, + onAgentStart: async () => {}, + onToolExecutionStart: () => {}, + onToolExecutionEnd: () => {}, + onMessageStart: async () => {}, + onMessageUpdate: async () => {}, + onAgentEnd: async () => { + events.push("agent-end"); + }, + onAgentSettled: () => { + events.push("agent-settled"); + }, + }); + await getRequiredLifecycleHandler(harness.handlers, "agent_end")( + event, + createLifecycleContext(), + ); + assert.deepEqual(events, expected, JSON.stringify({ settle, event })); + } +}); diff --git a/tests/model.test.ts b/tests/model.test.ts index a02d56a..b5768ce 100644 --- a/tests/model.test.ts +++ b/tests/model.test.ts @@ -83,6 +83,18 @@ test("Current model store prefers explicit Telegram model over context fallback" assert.equal(store.get({ model: fallback }), fallback); }); +test("Current model store follows the live context model on hosts without model_select", () => { + const stale = { provider: "stale", id: "a", name: "Stale" }; + const live = { provider: "live", id: "b", name: "Live" }; + const store = createCurrentModelStore<{ model?: typeof live }, typeof live>( + (ctx) => ctx.model, + true, + ); + store.set(stale); + assert.equal(store.get({ model: live }), live); + assert.equal(store.get({ model: undefined }), stale); +}); + test("Current model runtime combines store fallback and status updates", () => { const fallback = { provider: "fallback", id: "f", name: "Fallback" }; const selected = { provider: "selected", id: "s", name: "Selected" }; diff --git a/tests/paths.test.ts b/tests/paths.test.ts index 2f9552a..c6d6ae8 100644 --- a/tests/paths.test.ts +++ b/tests/paths.test.ts @@ -10,6 +10,7 @@ import test from "node:test"; import { resolveAgentDir, + setHostAgentDirResolver, resolveTelegramConfigPath, resolveTelegramLocksPath, resolveTelegramTempDir, @@ -29,41 +30,32 @@ test("Diagnostics use the resolved agent root and profile suffix", () => { }); await test("resolveAgentDir", async (t) => { - await t.test("returns PI_CODING_AGENT_DIR when env is set", () => { + t.afterEach(() => setHostAgentDirResolver(undefined)); + + await t.test("PI_CODING_AGENT_DIR wins over the host directory", () => { + setHostAgentDirResolver(() => { + throw new Error("host directory must not be consulted"); + }); assert.equal( - resolveAgentDir({ - env: { PI_CODING_AGENT_DIR: "/custom/agent/dir" }, - execPath: "/usr/bin/omp", - argv: ["omp"], - }), + resolveAgentDir({ env: { PI_CODING_AGENT_DIR: "/custom/agent/dir" } }), resolve("/custom/agent/dir"), ); }); - await t.test("returns ~/.omp/agent for OMP-compatible runtimes", () => { - assert.equal( - resolveAgentDir({ env: {}, execPath: "/home/user/.local/bin/omp" }), - join(homedir(), ".omp", "agent"), - ); - assert.equal( - resolveAgentDir({ - env: {}, - execPath: "/usr/bin/node", - argv: ["node", "omp"], - }), - join(homedir(), ".omp", "agent"), - ); - }); - await t.test( - "returns ~/.pi/agent as fallback when no env and no OMP runtime", + "uses the registered host runtime directory when env is unset", () => { - assert.equal( - resolveAgentDir({ env: {}, execPath: "/usr/bin/node", argv: ["node"] }), - join(homedir(), ".pi", "agent"), - ); + setHostAgentDirResolver(() => "/home/user/.omp/agent"); + assert.equal(resolveAgentDir({ env: {} }), "/home/user/.omp/agent"); }, ); + + await t.test("falls back to ~/.pi/agent when no host is registered", () => { + assert.equal( + resolveAgentDir({ env: {} }), + join(homedir(), ".pi", "agent"), + ); + }); }); await test("resolveTelegramConfigPath", () => { diff --git a/tests/pi.test.ts b/tests/pi.test.ts index 99e431e..2200d70 100644 --- a/tests/pi.test.ts +++ b/tests/pi.test.ts @@ -157,3 +157,15 @@ test("Pi context helpers expose model, idle, pending-message, and compact adapte assert.equal(hasExtensionContextPendingMessages(ctx), false); assert.deepEqual(events, ["compact", "complete"]); }); + +test("Pi context model prefers omp's live models facade over a frozen ctx.model", () => { + const frozen = { provider: "fake", id: "fake-1" }; + const live = { provider: "fake", id: "fake-2" }; + const ompCtx = { + model: frozen, + models: { current: () => live }, + } as unknown as ExtensionContext; + const piCtx = { model: frozen } as unknown as ExtensionContext; + assert.equal(getExtensionContextModel(ompCtx), live); + assert.equal(getExtensionContextModel(piCtx), frozen); +});