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
46 changes: 46 additions & 0 deletions docs/adr/0025-ohmypi-model-presets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# ADR 0025: Select OhMyPi model presets per thread

- Status: accepted
- Date: 2026-10-26
- Compared with upstream: `pingdotgg/t3code` at `6ffde5ba3` (2026-10-26)

OhMyPi (`omp`) is a Pi fork that T3 runs through the Pi provider. Its model
presets assign models to every role (`default`, `task`, `plan`, `smol`, ...)
and a default thinking level. Neither Pi RPC nor omp's ACP mode exposes them,
so T3 could only change the main model.

When the Pi binary reports an `omp/<version>` version, discovery reads
`omp config get modelPresets --json` and lists each preset as a model with the
slug `preset:<name>` and a "Preset" qualifier. Its Thinking select is the one
for the preset's main model and starts at the preset's level. Config reads pass
the instance's `--profile` and time out on their own, so a slow read only loses
the presets, not the discovered models.

Applying a preset with omp's own `/modelpreset switch` writes roles to the
user's global config, and every running omp process reloads it, so one thread
would change all the others and the user's terminal. T3 instead starts every
Pi session with its own overlay file in T3's cache, listed last in
`PI_CONFIG_FILES` (omp reads it like `--config`; plain Pi ignores it), empty
until a turn selects a preset. Selecting one rewrites the overlay with the preset's
roles, `null` tombstones for roles the preset leaves out (as `/modelpreset
switch` clears them), and its thinking level, read fresh from omp; then the
turn sets the main model and thinking over RPC. Selecting a plain model empties
the overlay again. omp watches overlay files and reloads them about 200ms after
a write, so role models switch from the next role lookup. A restart would avoid
that delay, but ordinary message dispatch never consults the adapter's
selection-transition plan, so switching has to work inside the live session.

Presets without a `default` role are not listed, because T3 sets the main model
from it, and one written as an alias rather than `provider/model` fails with a
clear error. omp's `--smol`, `--slow`, and `--plan` flags and their
`PI_*_MODEL` variables are runtime overrides that outrank any overlay. `omp
config get` cannot read `--config` overlays from the instance's launch
arguments and returns same-name presets merged across config layers, where omp
takes the highest layer's entry whole; both are accepted gaps. Text
generation has no per-model filter, so a preset chosen there uses only its
main model.

When updating from upstream, keep presets inside the Pi provider and keep the
per-process overlay; do not switch presets by writing omp's global config.
Remove this if Pi or omp exposes presets over RPC, or if upstream adds OhMyPi
support.
18 changes: 18 additions & 0 deletions docs/user/providers-pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ Pi loads its normal user and project extensions. Blocking `select`, `confirm`, `
dialogs work in T3 Code. Notifications appear in the work log. Pi terminal decoration such as
titles, status lines, and widgets does not have a T3 Code equivalent.

## OhMyPi Model Presets

If Pi's binary path points to [OhMyPi](https://github.com/can1357/oh-my-pi) (`omp`), its saved
model presets appear in the model picker labelled **Preset**. Save presets in `omp` with
`/modelpreset save <name>`, then refresh the provider in Settings to see new ones. The picker lists
presets saved in your user configuration; presets saved only in a project's configuration do not
appear.

Choosing a preset applies all of its role models and its thinking level to that thread only. Other
threads and `omp` sessions outside T3 Code keep their own models. You can switch presets, or switch
between a preset and a single model, at any point in a thread. Edits to a preset apply the next time
a thread switches to it or its session restarts. Use the picker rather than `/modelpreset switch`,
which changes OhMyPi's global configuration.

A preset needs a main (`default`) model written as `provider/model`. The `--smol`, `--slow`, and
`--plan` launch arguments, and the matching `PI_SMOL_MODEL`, `PI_SLOW_MODEL`, and `PI_PLAN_MODEL`
environment variables, override those roles in every preset.

## Permission Modes

T3 Code applies the composer permission mode through Pi's blocking tool hook:
Expand Down
246 changes: 240 additions & 6 deletions packages/provider-pi/src/server/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ interface FakePi {
readonly failNextState: () => void;
readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void;
readonly deferNextModelSelection: () => void;
readonly failNextModelSelection: () => void;
readonly queueModels: (models: ReadonlyArray<unknown>) => void;
readonly vetoNextNewSession: () => void;
readonly allRequests: () => ReadonlyArray<PiRpcRecord>;
Expand All @@ -190,6 +191,21 @@ const recordedIdleState = (sessionFile: string) => ({
pendingMessageCount: 0,
});

const exitedHandle = (stdout: string) =>
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(FAKE_PID),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: Stream.succeed(new TextEncoder().encode(stdout)),
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
});

const makeFakePi: Effect.Effect<FakePi> = Effect.gen(function* () {
const stdout = yield* Queue.unbounded<Uint8Array, Cause.Done>();
const requests = yield* Queue.unbounded<PiRpcRecord>();
Expand All @@ -201,6 +217,7 @@ const makeFakePi: Effect.Effect<FakePi> = Effect.gen(function* () {
let deferState = false;
let deferredStateRequest: PiRpcRecord | undefined;
let failState = false;
let failModelSelection = false;
let vetoSwitch = false;
let vetoNewSession = false;
let deferredLifecycle: string | undefined;
Expand Down Expand Up @@ -254,6 +271,10 @@ const makeFakePi: Effect.Effect<FakePi> = Effect.gen(function* () {
return base;
case "get_available_models":
return { ...base, data: { models } };
case "set_model":
if (!failModelSelection) return base;
failModelSelection = false;
return { ...base, success: false, error: "No API key for anthropic" };
case "new_session": {
const cancelled = vetoNewSession;
vetoNewSession = false;
Expand Down Expand Up @@ -371,6 +392,9 @@ const makeFakePi: Effect.Effect<FakePi> = Effect.gen(function* () {
deferNextModelSelection: () => {
deferredLifecycle = "set_model";
},
failNextModelSelection: () => {
failModelSelection = true;
},
queueModels: (value) => {
models = value;
},
Expand Down Expand Up @@ -428,6 +452,64 @@ const makeAdapter = Effect.fnUntraced(function* (
}).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
});

const OMP_CONFIG_VALUES: Record<string, unknown> = {
modelPresets: {
Anthropic: {
modelRoles: { default: "anthropic/claude-opus:high", task: "anthropic/claude-sonnet" },
defaultThinkingLevel: "auto",
},
Auto: { modelRoles: { default: "openai/gpt" }, defaultThinkingLevel: "auto" },
Unleveled: { modelRoles: { default: "openai/gpt" } },
},
modelRoles: { default: "openai/gpt", designer: "openai/gpt-mini" },
defaultThinkingLevel: "low",
};

/** Answers `omp config get <key> --json` and hands every other spawn to the fake Pi. */
const makeOmpPresetAdapter = (fake: FakePi) => {
const spawner = ChildProcessSpawner.make((command) => {
const args = ChildProcess.isStandardCommand(command) ? command.args : [];
if (!args.includes("config")) return fake.spawner.spawn(command);
return Effect.succeed(
exitedHandle(
encodeJsonLine({ value: OMP_CONFIG_VALUES[args[args.indexOf("config") + 2] ?? ""] }),
),
);
});
return makePiAdapterV2({
instanceId: PI_INSTANCE_ID,
settings: { enabled: true, binaryPath: "omp", launchArgs: "", customModels: [] },
environment: {},
}).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
};

const openOmpRuntime = Effect.fnUntraced(function* (
fake: FakePi,
model: string,
providerSessionId = SESSION_ID,
) {
const adapter = yield* makeOmpPresetAdapter(fake);
const runtime = yield* adapter.openSession({
threadId: THREAD_ID,
providerSessionId,
modelSelection: modelSelection(model),
runtimePolicy,
});
const emitted = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
yield* runtime.events.pipe(
Stream.runForEach((event) => Queue.offer(emitted, event)),
Effect.forkScoped,
);
const takeEvent = (predicate: (event: ProviderAdapter.ProviderAdapterV2Event) => boolean) =>
Effect.gen(function* () {
while (true) {
const event = yield* Queue.take(emitted);
if (predicate(event)) return event;
}
});
return { runtime, takeEvent };
});

const openRuntime = Effect.fnUntraced(function* (
fake: FakePi,
model = "default",
Expand Down Expand Up @@ -1465,13 +1547,14 @@ describe("PiAdapterV2", () => {
...fake,
spawner: ChildProcessSpawner.make((command) =>
Effect.acquireRelease(
fake.spawner
.spawn(command)
.pipe(
Effect.map((child) =>
ChildProcessSpawner.makeHandle({ ...child, exitCode: Deferred.await(exited) }),
),
fake.spawner.spawn(command).pipe(
Effect.map((child) =>
ChildProcessSpawner.makeHandle({
...child,
exitCode: Deferred.await(exited),
}),
),
),
() =>
Effect.sync(() => {
processReleased = true;
Expand Down Expand Up @@ -1818,6 +1901,157 @@ describe("PiAdapterV2", () => {
}).pipe(Effect.scoped, Effect.provide(layerTest)),
);

it.effect("switches an OhMyPi thread between presets and models through its own overlay", () =>
Effect.gen(function* () {
const fake = yield* makeFakePi;
const { runtime, takeEvent } = yield* openOmpRuntime(fake, "default");
const overlayPath = fake.lastSpawn().env.PI_CONFIG_FILES!;
const readOverlay = Effect.gen(function* () {
return decodeJsonLine(yield* (yield* FileSystem.FileSystem).readFileString(overlayPath));
});
assert.deepEqual(yield* readOverlay, {});
const providerThread = yield* runtime.ensureThread({
threadId: THREAD_ID,
modelSelection: modelSelection("default"),
runtimePolicy,
});
const settle = Effect.gen(function* () {
yield* fake.emit({ type: "agent_start" });
yield* fake.emit({ type: "agent_end", messages: [], willRetry: false });
yield* fake.emit({ type: "agent_settled" });
yield* takeEvent((event) => event.type === "turn.terminal");
});

yield* startTurn(runtime, providerThread, "preset:Anthropic");
const anthropicModel = yield* fake.takeRequest("set_model");
assert.deepEqual(
[anthropicModel["provider"], anthropicModel["modelId"]],
["anthropic", "claude-opus"],
);
assert.equal((yield* fake.takeRequest("set_thinking_level"))["level"], "high");
// Roles the preset leaves out are tombstoned so global roles cannot leak in.
assert.deepEqual(yield* readOverlay, {
modelRoles: {
default: "anthropic/claude-opus:high",
designer: null,
task: "anthropic/claude-sonnet",
},
defaultThinkingLevel: "auto",
});
yield* settle;

yield* startTurn(runtime, providerThread, "preset:Auto", [], "Hello pi", undefined, 2);
const autoModel = yield* fake.takeRequest("set_model");
assert.deepEqual([autoModel["provider"], autoModel["modelId"]], ["openai", "gpt"]);
assert.equal((yield* fake.takeRequest("set_thinking_level"))["level"], "auto");
assert.deepEqual(yield* readOverlay, {
modelRoles: { default: "openai/gpt", designer: null },
defaultThinkingLevel: "auto",
});
yield* settle;

yield* startTurn(runtime, providerThread, "xai/grok-4.6", [], "Hello pi", undefined, 3);
const plainModel = yield* fake.takeRequest("set_model");
assert.deepEqual([plainModel["provider"], plainModel["modelId"]], ["xai", "grok-4.6"]);
assert.deepEqual(yield* readOverlay, {});
}).pipe(Effect.scoped, Effect.provide(layerTest)),
);

it.effect("returns a preset without a thinking level to omp's configured level", () =>
Effect.gen(function* () {
const fake = yield* makeFakePi;
// A resumed transcript's level must not stand in for omp's configured default.
fake.queueState({ thinkingLevel: "high" });
const { runtime, takeEvent } = yield* openOmpRuntime(fake, "preset:Anthropic");
const providerThread = yield* runtime.ensureThread({
threadId: THREAD_ID,
modelSelection: modelSelection("preset:Anthropic"),
runtimePolicy,
});
yield* startTurn(runtime, providerThread, "preset:Anthropic");
assert.equal((yield* fake.takeRequest("set_thinking_level"))["level"], "high");
yield* fake.emit({ type: "agent_start" });
yield* fake.emit({ type: "agent_end", messages: [], willRetry: false });
yield* fake.emit({ type: "agent_settled" });
yield* takeEvent((event) => event.type === "turn.terminal");

yield* startTurn(runtime, providerThread, "preset:Unleveled", [], "Hello pi", undefined, 2);
assert.equal((yield* fake.takeRequest("set_thinking_level"))["level"], "low");
}).pipe(Effect.scoped, Effect.provide(layerTest)),
);

it.effect("keeps the overlay file name short for long session ids", () =>
Effect.gen(function* () {
const fake = yield* makeFakePi;
// Delegated-task thread ids embed request keys of up to 256 characters.
yield* openOmpRuntime(
fake,
"default",
ProviderSessionId.make(`provider-session:${"k".repeat(300)}`),
);
const overlayPath = fake.lastSpawn().env.PI_CONFIG_FILES!;
assert.isBelow(overlayPath.slice(overlayPath.lastIndexOf("/") + 1).length, 100);
assert.deepEqual(
decodeJsonLine(yield* (yield* FileSystem.FileSystem).readFileString(overlayPath)),
{},
);
}).pipe(Effect.scoped, Effect.provide(layerTest)),
);

it.effect("knows a preset's context window before its first turn", () =>
Effect.gen(function* () {
const fake = yield* makeFakePi;
fake.queueModels([
{ provider: "anthropic", id: "claude-opus", contextWindow: 1_000_000 },
{ provider: "openai", id: "gpt", contextWindow: 200_000 },
]);
const { runtime } = yield* openOmpRuntime(fake, "preset:Anthropic");
yield* runtime.ensureThread({
threadId: THREAD_ID,
modelSelection: modelSelection("preset:Anthropic"),
runtimePolicy,
});
assert.equal(runtime.getModelContextWindow?.(modelSelection("preset:Auto")), 200_000);
}).pipe(Effect.scoped, Effect.provide(layerTest)),
);

it.effect("clears a preset overlay whose switch failed when the thread switches back", () =>
Effect.gen(function* () {
const fake = yield* makeFakePi;
const { runtime } = yield* openOmpRuntime(fake, "xai/grok-4.6");
const overlayPath = fake.lastSpawn().env.PI_CONFIG_FILES!;
const providerThread = yield* runtime.ensureThread({
threadId: THREAD_ID,
modelSelection: modelSelection("xai/grok-4.6"),
runtimePolicy,
});
fake.failNextModelSelection();
yield* startTurn(runtime, providerThread, "preset:Anthropic").pipe(Effect.flip);

yield* startTurn(runtime, providerThread, "xai/grok-4.6", [], "Hello pi", undefined, 2);
yield* fake.takeRequest("prompt");
assert.deepEqual(
decodeJsonLine(yield* (yield* FileSystem.FileSystem).readFileString(overlayPath)),
{},
);
}).pipe(Effect.scoped, Effect.provide(layerTest)),
);

it.effect("fails a turn on a missing preset without changing the model", () =>
Effect.gen(function* () {
const fake = yield* makeFakePi;
const { runtime } = yield* openOmpRuntime(fake, "preset:Gone");
const providerThread = yield* runtime.ensureThread({
threadId: THREAD_ID,
modelSelection: modelSelection("preset:Gone"),
runtimePolicy,
});
const error = yield* startTurn(runtime, providerThread, "preset:Gone").pipe(Effect.flip);
assert.include(String(error.cause), "preset 'Gone' was not found");
assert.isUndefined(fake.allRequests().find((record) => record["type"] === "set_model"));
}).pipe(Effect.scoped, Effect.provide(layerTest)),
);

it.effect.each([
{ historical: false, label: "the latest turn" },
{ historical: true, label: "a historical turn" },
Expand Down
Loading
Loading