fix: pin embedded harness selection per session

This commit is contained in:
Peter Steinberger
2026-04-23 16:51:42 +01:00
parent 7248a7749f
commit 1713839288
23 changed files with 518 additions and 11 deletions

View File

@@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai
- Plugins/install: add newly installed plugin ids to an existing `plugins.allow` list before enabling them, so allowlisted configs load installed plugins after restart.
- Status: show `Fast` in `/status` when fast mode is enabled, including config/default-derived fast mode, and omit it when disabled.
- OpenAI/image generation: detect Azure OpenAI-style image endpoints, use Azure `api-key` auth plus deployment-scoped image URLs, and honor `AZURE_OPENAI_API_VERSION` so image generation and edits work against Azure-hosted OpenAI resources. (#70570) Thanks @zhanggpcsu.
- Codex harness/status: pin embedded harness selection per session, show active non-PI harness ids such as `codex` in `/status`, and keep legacy transcripts on PI until `/new` or `/reset` so config changes cannot hot-switch existing sessions.
- Models/auth: merge provider-owned default-model additions from `openclaw models auth login` instead of replacing `agents.defaults.models`, so re-authenticating an OAuth provider such as OpenAI Codex no longer wipes other providers' aliases and per-model params. Migrations that must rename keys (Anthropic -> Claude CLI) opt in with `replaceDefaultModels`. Fixes #69414. (#70435) Thanks @neeravmakwana.
- Media understanding/audio: prefer configured or key-backed STT providers before auto-detected local Whisper CLIs, so installed local transcription tools no longer shadow API providers such as Groq/OpenAI in `tools.media.audio` auto mode. Fixes #68727.
- Providers/OpenAI: lock the auth picker wording for OpenAI API key, Codex browser login, and Codex device pairing so the setup choices no longer imply a mixed Codex/API-key auth path. (#67848) Thanks @tmlxrd.

View File

@@ -1271,6 +1271,7 @@ Codex app-server harness.
- `fallback`: `"pi"` or `"none"`. `"pi"` keeps the built-in PI harness as the compatibility fallback when no plugin harness is selected. `"none"` makes missing or unsupported plugin harness selection fail instead of silently using PI. Selected plugin harness failures always surface directly.
- Environment overrides: `OPENCLAW_AGENT_RUNTIME=<id|auto|pi>` overrides `runtime`; `OPENCLAW_AGENT_HARNESS_FALLBACK=none` disables PI fallback for that process.
- For Codex-only deployments, set `model: "codex/gpt-5.4"`, `embeddedHarness.runtime: "codex"`, and `embeddedHarness.fallback: "none"`.
- Harness choice is pinned per session id after the first embedded run. Config/env changes affect new or reset sessions, not an existing transcript. Legacy sessions with transcript history but no recorded pin are treated as PI-pinned. `/status` shows non-PI harness ids such as `codex` next to `Fast`.
- This only controls the embedded chat harness. Media generation, vision, PDF, music, video, and TTS still use their provider/model settings.
**Built-in alias shorthands** (only apply when the model is in `agents.defaults.models`):

View File

@@ -51,6 +51,21 @@ The Codex harness only claims `codex/*` model refs. Existing `openai/*`,
`openai-codex/*`, Anthropic, Gemini, xAI, local, and custom provider refs keep
their normal paths.
Harness selection is not a live session control. When an embedded turn runs,
OpenClaw records the selected harness id on that session and keeps using it for
later turns in the same session id. Change `embeddedHarness` config or
`OPENCLAW_AGENT_RUNTIME` when you want future sessions to use another harness;
use `/new` or `/reset` to start a fresh session before switching an existing
conversation between PI and Codex. This avoids replaying one transcript through
two incompatible native session systems.
Legacy sessions created before harness pins are treated as PI-pinned once they
have transcript history. Use `/new` or `/reset` to opt that conversation into
Codex after changing config.
`/status` shows the effective non-PI harness next to `Fast`, for example
`Fast · codex`. The default PI harness is omitted.
## Requirements
- OpenClaw with the bundled `codex` plugin available.
@@ -218,7 +233,8 @@ auto-selection:
Use normal session commands to switch agents and models. `/new` creates a fresh
OpenClaw session and the Codex harness creates or resumes its sidecar app-server
thread as needed. `/reset` clears the OpenClaw session binding for that thread.
thread as needed. `/reset` clears the OpenClaw session binding for that thread
and lets the next turn resolve the harness from current config again.
## Model discovery

View File

@@ -87,11 +87,14 @@ export default definePluginEntry({
OpenClaw chooses a harness after provider/model resolution:
1. `OPENCLAW_AGENT_RUNTIME=<id>` forces a registered harness with that id.
2. `OPENCLAW_AGENT_RUNTIME=pi` forces the built-in PI harness.
3. `OPENCLAW_AGENT_RUNTIME=auto` asks registered harnesses if they support the
1. An existing session's recorded harness id wins, so config/env changes do not
hot-switch that transcript to another runtime.
2. `OPENCLAW_AGENT_RUNTIME=<id>` forces a registered harness with that id for
sessions that are not already pinned.
3. `OPENCLAW_AGENT_RUNTIME=pi` forces the built-in PI harness.
4. `OPENCLAW_AGENT_RUNTIME=auto` asks registered harnesses if they support the
resolved provider/model.
4. If no registered harness matches, OpenClaw uses PI unless PI fallback is
5. If no registered harness matches, OpenClaw uses PI unless PI fallback is
disabled.
Plugin harness failures surface as run failures. In `auto` mode, PI fallback is
@@ -100,6 +103,12 @@ provider/model. Once a plugin harness has claimed a run, OpenClaw does not
replay that same turn through PI because that can change auth/runtime semantics
or duplicate side effects.
The selected harness id is persisted with the session id after an embedded run.
Legacy sessions created before harness pins are treated as PI-pinned once they
have transcript history. Use a new/reset session when changing between PI and a
native plugin harness. `/status` shows non-default harness ids such as `codex`
next to `Fast`; PI stays hidden because it is the default compatibility path.
The bundled Codex plugin registers `codex` as its harness id. Core treats that
as an ordinary plugin harness id; Codex-specific aliases belong in the plugin
or operator config, not in the shared runtime selector.

View File

@@ -5,10 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { FailoverError } from "../failover-error.js";
import type { EmbeddedPiRunResult } from "../pi-embedded.js";
import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../pi-embedded.js";
import { persistCliTurnTranscript, runAgentAttempt } from "./attempt-execution.js";
const runCliAgentMock = vi.hoisted(() => vi.fn());
const runEmbeddedPiAgentMock = vi.hoisted(() => vi.fn());
const ORIGINAL_HOME = process.env.HOME;
vi.mock("../cli-runner.js", () => ({
@@ -21,7 +22,7 @@ vi.mock("../model-selection.js", () => ({
}));
vi.mock("../pi-embedded.js", () => ({
runEmbeddedPiAgent: vi.fn(),
runEmbeddedPiAgent: runEmbeddedPiAgentMock,
}));
function makeCliResult(text: string): EmbeddedPiRunResult {
@@ -73,6 +74,7 @@ describe("CLI attempt execution", () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-attempt-"));
storePath = path.join(tmpDir, "sessions.json");
runCliAgentMock.mockReset();
runEmbeddedPiAgentMock.mockReset();
});
afterEach(async () => {
@@ -386,3 +388,102 @@ describe("CLI attempt execution", () => {
);
});
});
describe("embedded attempt harness pinning", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-embedded-attempt-"));
runEmbeddedPiAgentMock.mockReset();
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("treats legacy sessions with history as PI-pinned", async () => {
const sessionEntry: SessionEntry = {
sessionId: "legacy-session",
updatedAt: Date.now(),
};
runEmbeddedPiAgentMock.mockResolvedValueOnce({
meta: { durationMs: 1 },
} satisfies EmbeddedPiRunResult);
await runAgentAttempt({
providerOverride: "openai",
modelOverride: "gpt-5.4",
cfg: {} as OpenClawConfig,
sessionEntry,
sessionId: sessionEntry.sessionId,
sessionKey: "agent:main:main",
sessionAgentId: "main",
sessionFile: path.join(tmpDir, "session.jsonl"),
workspaceDir: tmpDir,
body: "continue",
isFallbackRetry: false,
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-legacy-pi-pin",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
skillsSnapshot: undefined,
resolvedVerboseLevel: undefined,
agentDir: tmpDir,
onAgentEvent: vi.fn(),
authProfileProvider: "openai",
sessionHasHistory: true,
});
expect(runEmbeddedPiAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentHarnessId: "pi",
}),
);
});
it("leaves a fresh unpinned session on config-selected harness resolution", async () => {
const sessionEntry: SessionEntry = {
sessionId: "fresh-session",
updatedAt: Date.now(),
};
runEmbeddedPiAgentMock.mockResolvedValueOnce({
meta: { durationMs: 1 },
} satisfies EmbeddedPiRunResult);
await runAgentAttempt({
providerOverride: "openai",
modelOverride: "gpt-5.4",
cfg: {} as OpenClawConfig,
sessionEntry,
sessionId: sessionEntry.sessionId,
sessionKey: "agent:main:main",
sessionAgentId: "main",
sessionFile: path.join(tmpDir, "session.jsonl"),
workspaceDir: tmpDir,
body: "start",
isFallbackRetry: false,
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-fresh-no-pin",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
skillsSnapshot: undefined,
resolvedVerboseLevel: undefined,
agentDir: tmpDir,
onAgentEvent: vi.fn(),
authProfileProvider: "openai",
sessionHasHistory: false,
});
expect(runEmbeddedPiAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentHarnessId: undefined,
}),
);
});
});

View File

@@ -262,6 +262,10 @@ export function runAgentAttempt(params: {
);
const bootstrapPromptWarningSignature =
bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1];
const sessionPinnedAgentHarnessId =
params.sessionEntry?.sessionId === params.sessionId
? (params.sessionEntry.agentHarnessId ?? (params.sessionHasHistory ? "pi" : undefined))
: undefined;
const authProfileId =
params.providerOverride === params.authProfileProvider
? params.sessionEntry?.authProfileOverride
@@ -407,6 +411,7 @@ export function runAgentAttempt(params: {
sessionFile: params.sessionFile,
workspaceDir: params.workspaceDir,
config: params.cfg,
agentHarnessId: sessionPinnedAgentHarnessId,
skillsSnapshot: params.skillsSnapshot,
prompt: effectivePrompt,
images: params.isFallbackRetry ? undefined : params.opts.images,

View File

@@ -144,6 +144,99 @@ async function withTempSessionStore<T>(
}
describe("updateSessionStoreAfterAgentRun", () => {
it("persists the selected embedded harness id on the session", async () => {
await withTempSessionStore(async ({ storePath }) => {
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:explicit:test-harness-pin";
const sessionId = "test-harness-pin-session";
const sessionStore: Record<string, SessionEntry> = {
[sessionKey]: {
sessionId,
updatedAt: 1,
},
};
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
const result: EmbeddedPiRunResult = {
meta: {
durationMs: 1,
agentMeta: {
sessionId,
provider: "openai",
model: "gpt-5.4",
agentHarnessId: "codex",
},
},
};
await updateSessionStoreAfterAgentRun({
cfg,
sessionId,
sessionKey,
storePath,
sessionStore,
defaultProvider: "openai",
defaultModel: "gpt-5.4",
result,
});
expect(sessionStore[sessionKey]?.agentHarnessId).toBe("codex");
expect(loadSessionStore(storePath)[sessionKey]?.agentHarnessId).toBe("codex");
});
});
it("clears the embedded harness pin after a CLI run", async () => {
await withTempSessionStore(async ({ storePath }) => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
},
},
},
},
} as OpenClawConfig;
const sessionKey = "agent:main:explicit:test-harness-pin-cli";
const sessionId = "test-harness-pin-cli-session";
const sessionStore: Record<string, SessionEntry> = {
[sessionKey]: {
sessionId,
updatedAt: 1,
agentHarnessId: "codex",
},
};
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
const result: EmbeddedPiRunResult = {
meta: {
durationMs: 1,
executionTrace: { runner: "cli" },
agentMeta: {
sessionId: "cli-session-123",
provider: "claude-cli",
model: "claude-sonnet-4-6",
},
},
};
await updateSessionStoreAfterAgentRun({
cfg,
sessionId,
sessionKey,
storePath,
sessionStore,
defaultProvider: "claude-cli",
defaultModel: "claude-sonnet-4-6",
result,
});
expect(sessionStore[sessionKey]?.agentHarnessId).toBeUndefined();
expect(loadSessionStore(storePath)[sessionKey]?.agentHarnessId).toBeUndefined();
});
});
it("persists claude-cli session bindings when the backend is configured", async () => {
await withTempSessionStore(async ({ storePath }) => {
const cfg = {

View File

@@ -5,6 +5,7 @@ import {
updateSessionStore,
} from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { clearCliSession, setCliSessionBinding, setCliSessionId } from "../cli-session.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { isCliProvider } from "../model-selection.js";
@@ -60,6 +61,7 @@ export async function updateSessionStoreAfterAgentRun(params: {
const compactionsThisRun = Math.max(0, result.meta.agentMeta?.compactionCount ?? 0);
const modelUsed = result.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed = result.meta.agentMeta?.provider ?? fallbackProvider ?? defaultProvider;
const agentHarnessId = normalizeOptionalString(result.meta.agentMeta?.agentHarnessId);
const contextTokens =
typeof params.contextTokensOverride === "number" && params.contextTokensOverride > 0
? params.contextTokensOverride
@@ -85,6 +87,11 @@ export async function updateSessionStoreAfterAgentRun(params: {
provider: providerUsed,
model: modelUsed,
});
if (agentHarnessId) {
next.agentHarnessId = agentHarnessId;
} else if (result.meta.executionTrace?.runner === "cli") {
next.agentHarnessId = undefined;
}
if (isCliProvider(providerUsed, cfg)) {
const cliSessionBinding = result.meta.agentMeta?.cliSessionBinding;
if (cliSessionBinding?.sessionId?.trim()) {

View File

@@ -6,7 +6,11 @@ import type {
EmbeddedRunAttemptResult,
} from "../pi-embedded-runner/run/types.js";
import { clearAgentHarnesses, registerAgentHarness } from "./registry.js";
import { runAgentHarnessAttemptWithFallback, selectAgentHarness } from "./selection.js";
import {
maybeCompactAgentHarnessSession,
runAgentHarnessAttemptWithFallback,
selectAgentHarness,
} from "./selection.js";
import type { AgentHarness } from "./types.js";
const piRunAttempt = vi.fn(async () => createAttemptResult("pi"));
@@ -182,4 +186,50 @@ describe("selectAgentHarness", () => {
"pi",
);
});
it("keeps an existing session pinned to PI even when config now forces a plugin harness", () => {
registerFailingCodexHarness();
expect(
selectAgentHarness({
provider: "codex",
modelId: "gpt-5.4",
agentHarnessId: "pi",
config: { agents: { defaults: { embeddedHarness: { runtime: "codex" } } } },
}).id,
).toBe("pi");
});
it("keeps an existing session pinned to its plugin harness even when env now forces PI", () => {
process.env.OPENCLAW_AGENT_RUNTIME = "pi";
registerFailingCodexHarness();
expect(
selectAgentHarness({
provider: "openai",
modelId: "gpt-5.4",
agentHarnessId: "codex",
}).id,
).toBe("codex");
});
it("does not compact a plugin-pinned session through PI when the plugin has no compactor", async () => {
registerFailingCodexHarness();
await expect(
maybeCompactAgentHarnessSession({
sessionId: "session-1",
sessionKey: "agent:main:main",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace",
provider: "openai",
model: "gpt-5.4",
agentHarnessId: "codex",
}),
).resolves.toEqual({
ok: false,
compacted: false,
reason: 'Agent harness "codex" does not support compaction.',
});
});
});

View File

@@ -49,8 +49,10 @@ export function selectAgentHarness(params: {
config?: OpenClawConfig;
agentId?: string;
sessionKey?: string;
agentHarnessId?: string;
}): AgentHarness {
const policy = resolveAgentHarnessPolicy(params);
const policy =
resolvePinnedAgentHarnessPolicy(params.agentHarnessId) ?? resolveAgentHarnessPolicy(params);
// PI is intentionally not part of the plugin candidate list. It is the legacy
// fallback path, so `fallback: "none"` can prove that only plugin harnesses run.
const pluginHarnesses = listPluginAgentHarnesses();
@@ -115,13 +117,16 @@ export async function runAgentHarnessAttemptWithFallback(
config: params.config,
agentId: params.agentId,
sessionKey: params.sessionKey,
agentHarnessId: params.agentHarnessId,
});
if (harness.id === "pi") {
return harness.runAttempt(params);
const result = await harness.runAttempt(params);
return { ...result, agentHarnessId: harness.id };
}
try {
return await harness.runAttempt(params);
const result = await harness.runAttempt(params);
return { ...result, agentHarnessId: harness.id };
} catch (error) {
log.warn(`${harness.label} failed; not falling back to embedded PI backend`, {
harnessId: harness.id,
@@ -133,6 +138,16 @@ export async function runAgentHarnessAttemptWithFallback(
}
}
function resolvePinnedAgentHarnessPolicy(
agentHarnessId: string | undefined,
): AgentHarnessPolicy | undefined {
const runtime = normalizeEmbeddedAgentRuntime(agentHarnessId);
if (runtime === "auto") {
return undefined;
}
return { runtime, fallback: "none" };
}
export async function maybeCompactAgentHarnessSession(
params: CompactEmbeddedPiSessionParams,
): Promise<EmbeddedPiCompactResult | undefined> {
@@ -141,8 +156,16 @@ export async function maybeCompactAgentHarnessSession(
modelId: params.model,
config: params.config,
sessionKey: params.sessionKey,
agentHarnessId: params.agentHarnessId,
});
if (!harness.compact) {
if (harness.id !== "pi") {
return {
ok: false,
compacted: false,
reason: `Agent harness "${harness.id}" does not support compaction.`,
};
}
return undefined;
}
return harness.compact(params);

View File

@@ -41,6 +41,8 @@ export type CompactEmbeddedPiSessionParams = {
skillsSnapshot?: SkillSnapshot;
provider?: string;
model?: string;
/** Session-pinned embedded harness id. Prevents compaction hot-switching. */
agentHarnessId?: string;
thinkLevel?: ThinkLevel;
reasoningLevel?: ReasoningLevel;
bashElevated?: ExecElevatedDefaults;

View File

@@ -731,6 +731,7 @@ export async function runEmbeddedPiAgent(
disableTools: params.disableTools,
provider,
modelId,
agentHarnessId: params.agentHarnessId,
model: applyAuthHeaderOverride(
applyLocalNoAuthHeaderOverride(effectiveModel, apiKeyInfo),
// When runtime auth exchange produced a different credential
@@ -1629,6 +1630,7 @@ export async function runEmbeddedPiAgent(
sessionId: sessionIdUsed,
provider: sessionLastAssistant?.provider ?? provider,
model: sessionLastAssistant?.model ?? model.id,
agentHarnessId: attempt.agentHarnessId,
usage: usageMeta.usage,
lastCallUsage: usageMeta.lastCallUsage,
promptTokens: usageMeta.promptTokens,

View File

@@ -86,6 +86,8 @@ export type RunEmbeddedPiAgentParams = {
disableTools?: boolean;
provider?: string;
model?: string;
/** Session-pinned embedded harness id. Prevents runtime hot-switching. */
agentHarnessId?: string;
authProfileId?: string;
authProfileIdSource?: "auto" | "user";
thinkLevel?: ThinkLevel;

View File

@@ -32,6 +32,8 @@ export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & {
authProfileIdSource?: "auto" | "user";
provider: string;
modelId: string;
/** Session-pinned embedded harness id. Prevents runtime hot-switching. */
agentHarnessId?: string;
model: Model<Api>;
authStorage: AuthStorage;
modelRegistry: ModelRegistry;
@@ -70,6 +72,7 @@ export type EmbeddedRunAttemptResult = {
handled?: false;
};
sessionIdUsed: string;
agentHarnessId?: string;
bootstrapPromptWarningSignaturesSeen?: string[];
bootstrapPromptWarningSignature?: string;
systemPromptReport?: SessionSystemPromptReport;

View File

@@ -5,6 +5,7 @@ export type EmbeddedPiAgentMeta = {
sessionId: string;
provider: string;
model: string;
agentHarnessId?: string;
cliSessionBinding?: CliSessionBinding;
compactionCount?: number;
promptTokens?: number;

View File

@@ -481,6 +481,8 @@ export async function runPreflightCompactionIfNeeded(params: {
skillsSnapshot: entry.skillsSnapshot ?? params.followupRun.run.skillsSnapshot,
provider: params.followupRun.run.provider,
model: params.followupRun.run.model,
agentHarnessId:
entry.sessionId === params.followupRun.run.sessionId ? entry.agentHarnessId : undefined,
thinkLevel: params.followupRun.run.thinkLevel,
bashElevated: params.followupRun.run.bashElevated,
trigger: "budget",

View File

@@ -143,6 +143,8 @@ export const handleCompactCommand: CommandHandler = async (params) => {
skillsSnapshot: targetSessionEntry.skillsSnapshot,
provider: params.provider,
model: params.model,
agentHarnessId:
targetSessionEntry.sessionId === sessionId ? targetSessionEntry.agentHarnessId : undefined,
thinkLevel: params.resolvedThinkLevel ?? (await params.resolveDefaultThinkingLevel()),
bashElevated: {
enabled: false,

View File

@@ -3,6 +3,8 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { normalizeTestText } from "../../../test/helpers/normalize-text.js";
import { withTempHome } from "../../../test/helpers/temp-home.js";
import { clearAgentHarnesses, registerAgentHarness } from "../../agents/harness/registry.js";
import type { AgentHarness } from "../../agents/harness/types.js";
import {
addSubagentRunForTests,
resetSubagentRegistryForTests,
@@ -50,6 +52,23 @@ async function buildStatusReplyForTest(params: { sessionKey?: string; verbose?:
});
}
function registerStatusCodexHarness(): void {
const harness: AgentHarness = {
id: "codex",
label: "Codex",
supports: (ctx) =>
ctx.provider === "codex" ? { supported: true, priority: 100 } : { supported: false },
runAttempt: async () => {
throw new Error("not used in status tests");
},
};
registerAgentHarness(harness, { ownerPluginId: "codex" });
}
afterEach(() => {
clearAgentHarnesses();
});
function writeTranscriptUsageLog(params: {
dir: string;
agentId: string;
@@ -461,4 +480,81 @@ describe("buildStatusReply subagent summary", () => {
expect(normalizeTestText(text)).toContain("Context: 1.0k/32k");
});
});
it("shows the effective non-PI embedded harness in /status", async () => {
registerStatusCodexHarness();
const text = await buildStatusText({
cfg: {
...baseCfg,
agents: {
defaults: {
embeddedHarness: { runtime: "codex" },
},
},
},
sessionEntry: {
sessionId: "sess-status-codex",
updatedAt: 0,
fastMode: true,
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
sessionScope: "per-sender",
statusChannel: "mobilechat",
provider: "openai",
model: "gpt-5.4",
contextTokens: 32_000,
resolvedFastMode: true,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
modelAuthOverride: "api-key",
activeModelAuthOverride: "api-key",
});
expect(normalizeTestText(text)).toContain("Fast · codex");
});
it("keeps /status on a session-pinned PI harness after config changes", async () => {
registerStatusCodexHarness();
const text = await buildStatusText({
cfg: {
...baseCfg,
agents: {
defaults: {
embeddedHarness: { runtime: "codex" },
},
},
},
sessionEntry: {
sessionId: "sess-status-pinned-pi",
updatedAt: 0,
fastMode: true,
agentHarnessId: "pi",
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
sessionScope: "per-sender",
statusChannel: "mobilechat",
provider: "openai",
model: "gpt-5.4",
contextTokens: 32_000,
resolvedFastMode: true,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
modelAuthOverride: "api-key",
activeModelAuthOverride: "api-key",
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Fast");
expect(normalized).not.toContain("codex");
});
});

View File

@@ -382,6 +382,43 @@ describe("buildStatusMessage", () => {
expect(normalizeTestText(text)).toContain("Fast");
});
it("shows a non-PI harness next to fast mode when resolved", () => {
const text = buildStatusMessage({
agent: {
model: "openai/gpt-5.4",
},
sessionEntry: {
sessionId: "codex-harness",
updatedAt: 0,
fastMode: true,
},
sessionKey: "agent:main:main",
queue: { mode: "collect", depth: 0 },
resolvedHarness: "codex",
});
expect(normalizeTestText(text)).toContain("Fast · codex");
});
it("hides the default PI harness label", () => {
const text = buildStatusMessage({
agent: {
model: "openai/gpt-5.4",
},
sessionEntry: {
sessionId: "pi-harness",
updatedAt: 0,
fastMode: true,
},
sessionKey: "agent:main:main",
queue: { mode: "collect", depth: 0 },
resolvedHarness: "pi",
});
expect(normalizeTestText(text)).toContain("Fast");
expect(normalizeTestText(text)).not.toContain("· pi");
});
it("hides fast mode when disabled", () => {
const text = buildStatusMessage({
agent: {

View File

@@ -221,6 +221,12 @@ export type SessionEntry = {
cacheWrite?: number;
modelProvider?: string;
model?: string;
/**
* Embedded agent harness selected for this session id.
* Prevents config/env changes from moving an existing transcript between
* incompatible runtime harnesses.
*/
agentHarnessId?: string;
/**
* Last selected/runtime model pair for which a fallback notice was emitted.
* Used to avoid repeating the same fallback notice every turn.

View File

@@ -1571,6 +1571,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
config: cfg,
provider: resolvedModel.provider,
model: resolvedModel.model,
agentHarnessId: entry?.sessionId === sessionId ? entry.agentHarnessId : undefined,
thinkLevel: normalizeThinkLevel(entry?.thinkingLevel),
reasoningLevel: normalizeReasoningLevel(entry?.reasoningLevel),
bashElevated: {

View File

@@ -87,6 +87,7 @@ export type StatusArgs = {
groupActivation?: "mention" | "always";
resolvedThink?: ThinkLevel;
resolvedFast?: boolean;
resolvedHarness?: string;
resolvedVerbose?: VerboseLevel;
resolvedReasoning?: ReasoningLevel;
resolvedElevated?: ElevatedLevel;
@@ -269,6 +270,14 @@ const formatFastModeLabel = (enabled: boolean) => {
return "Fast";
};
const formatHarnessLabel = (harnessId: string | undefined) => {
const normalized = normalizeOptionalLowercaseString(harnessId);
if (!normalized || normalized === "pi" || normalized === "auto") {
return null;
}
return normalized;
};
const readUsageFromSessionLog = (
sessionId?: string,
sessionEntry?: SessionEntry,
@@ -744,6 +753,7 @@ export function buildStatusMessage(args: StatusArgs): string {
`Runner: ${runnerLabel}`,
`Think: ${thinkLevel}`,
formatFastModeLabel(fastMode),
formatHarnessLabel(args.resolvedHarness),
textVerbosity ? `Text: ${textVerbosity}` : null,
verboseLabel,
traceLabel,

View File

@@ -6,6 +6,7 @@ import {
resolveAgentModelFallbacksOverride,
} from "../agents/agent-scope.js";
import { resolveFastModeState } from "../agents/fast-mode.js";
import { selectAgentHarness } from "../agents/harness/selection.js";
import { resolveModelAuthLabel } from "../agents/model-auth-label.js";
import {
resolveInternalSessionKey,
@@ -52,6 +53,7 @@ export type BuildStatusTextParams = {
contextTokens?: number;
resolvedThinkLevel?: ThinkLevel;
resolvedFastMode?: boolean;
resolvedHarness?: string;
resolvedVerboseLevel: VerboseLevel;
resolvedReasoningLevel: ReasoningLevel;
resolvedElevatedLevel?: ElevatedLevel;
@@ -131,6 +133,30 @@ function formatSessionTaskLine(sessionKey: string): string | undefined {
return parts.length ? `📌 Tasks: ${parts.join(" · ")}` : undefined;
}
function resolveStatusHarnessId(params: {
cfg: OpenClawConfig;
provider: string;
model: string;
agentId: string;
sessionKey: string;
sessionEntry?: SessionEntry;
}): string | undefined {
try {
const selected = selectAgentHarness({
provider: params.provider,
modelId: params.model,
config: params.cfg,
agentId: params.agentId,
sessionKey: params.sessionKey,
agentHarnessId: params.sessionEntry?.agentHarnessId,
});
const id = normalizeOptionalLowercaseString(selected.id);
return id && id !== "pi" ? id : undefined;
} catch {
return undefined;
}
}
function formatAgentTaskCountsLine(agentId: string): string | undefined {
const snapshot = buildTaskStatusSnapshot(listTasksForAgentIdForStatus(agentId));
if (snapshot.totalCount === 0) {
@@ -286,6 +312,16 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
agentId: statusAgentId,
sessionEntry,
}).enabled;
const effectiveHarness =
params.resolvedHarness ??
resolveStatusHarnessId({
cfg,
provider,
model,
agentId: statusAgentId,
sessionKey,
sessionEntry,
});
const agentFallbacksOverride = resolveAgentModelFallbacksOverride(cfg, statusAgentId);
const { buildStatusMessage } = await loadStatusMessageRuntime();
const explicitThinkingDefault =
@@ -319,6 +355,7 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
resolvedThink:
resolvedThinkLevel ?? explicitThinkingDefault ?? (await resolveDefaultThinkingLevel()),
resolvedFast: effectiveFastMode,
resolvedHarness: effectiveHarness,
resolvedVerbose: resolvedVerboseLevel,
resolvedReasoning: resolvedReasoningLevel,
resolvedElevated: resolvedElevatedLevel,