[codex] Add contract-first Pi/Codex runtime plan suite (#71096)

* test: add pi codex runtime contract coverage

* test: expand pi codex tool runtime contracts

* test: tighten tool runtime contracts

* test: reset tool contract param cache

* test: document codex tool middleware fixture

* test: type pi tool contract events

* test: satisfy pi tool contract test types

* test: cover tool media telemetry contracts

* test: reset plugin runtime after tool contracts

* test: add auth profile runtime contracts

* test: strengthen auth profile runtime contracts

* test: clarify auth profile contract fixtures

* test: expand auth profile contract matrix

* test: assert unrelated cli auth isolation

* test: expand auth profile contract matrix

* test: tighten auth profile contract expectations

* test: add outcome fallback runtime contracts

* test: strengthen outcome fallback contracts

* test: isolate outcome fallback contracts

* test: cover codex terminal outcome signals

* test: expand terminal fallback contracts

* test: add delivery no reply runtime contracts

* test: document json no-reply delivery gap

* test: align delivery contract fixtures

* test: add transcript repair runtime contracts

* test: tighten transcript repair contracts

* test: add prompt overlay runtime contracts

* test: tighten prompt overlay contract scope

* test: type prompt overlay contracts

* test: add schema normalization runtime contracts

* test: clarify schema normalization contract gaps

* test: simplify schema normalization contracts

* test: tighten schema normalization contract gaps

* test: cover compaction schema contract

* test: satisfy schema contract lint

* test: add transport params runtime contracts

* test: tighten transport params contract scope

* test: isolate transport params contracts

* test: lock exact transport defaults

* feat: add agent runtime plan foundation

* fix: preserve codex harness auth profiles

* fix: route followup delivery through runtime plan

* fix: normalize parameter-free openai tool schemas

* fix: satisfy runtime plan type checks

* fix: narrow followup delivery runtime planning

* fix: apply codex app-server auth profiles

* fix: classify codex terminal outcomes

* fix: prevent harness auth leakage into unrelated cli providers

* feat: expand agent runtime plan policy contract

* fix: route pi runtime policy through runtime plan

* fix: route codex runtime policy through runtime plan

* fix: route fallback outcome classification through runtime plan

* refactor: make runtime plan contracts topology-safe

* fix: restore runtime plan test type coverage

* fix: align runtime plan schema contract assertions

* fix: stabilize incomplete turn runtime tests

* fix: stabilize codex native web search test

* fix: preserve codex auth profile secret refs

* fix: keep runtime resolved refs canonical

* fix: preserve permissive nested openai schemas

* fix: accept Codex auth provider aliases

* test: update media-only groups mock

* fix: resolve runtime plan rebase checks

* fix: resolve runtime plan rebase checks

---------

Co-authored-by: Eva <eva@100yen.org>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
EVA
2026-04-25 00:34:01 +07:00
committed by GitHub
parent ec3dbd22a4
commit 860dad268d
61 changed files with 5087 additions and 195 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
- Models/CLI: split `openclaw models list` row-source orchestration and registry loading into narrower helpers without changing list output behavior. (#70867) Thanks @shakkernerd.
- Models/commands: deprecate `/models add` so chat attempts now return a deprecation message instead of writing model configuration, and remove the add action from `/models` provider menus.
- Codex harness/context-engine: run context-engine bootstrap, assembly, post-turn maintenance, and engine-owned compaction in Codex app-server sessions while keeping native Codex thread state and compaction auditable. (#70809) Thanks @jalehman.
- Codex runtime plan: consolidate contract-first Pi/Codex parity coverage and accept legacy Codex auth-provider aliases in app-server profile login and refresh paths. (#71096) Thanks @100yenadmin.
- Plugins/Google Meet: add a bundled participant plugin with personal Google auth, explicit meeting URL joins, Chrome and Twilio transports, and realtime voice support. (#70765) Thanks @steipete.
- Plugins/Google Meet: default Chrome realtime sessions to OpenAI plus SoX `rec`/`play` audio bridge commands, so the usual setup only needs the plugin enabled and `OPENAI_API_KEY`.
- Plugins/Google Meet: add a `chrome-node` transport so a paired macOS node, such as a Parallels VM, can own Chrome, BlackHole, and SoX while the Gateway machine keeps the agent and model key.

View File

@@ -1,2 +1,2 @@
3e0d36fbe1db58f01c297a35c9a26d1037471720a8e71dc7149d108bf0f9bf40 plugin-sdk-api-baseline.json
aa4065f3efaf8ed6f7641ad7384039123e5bbb21a3e682f7599ca75195ceb8cd plugin-sdk-api-baseline.jsonl
c4a62f081d0b9fcfd5e76a843547411bba0fdc129c1c143e7f4c4f6294b040b9 plugin-sdk-api-baseline.json
a62c9aea45d5694a851380ff6b35b7fb2ffd9fc4dfa3f0c567a8e1c97094475e plugin-sdk-api-baseline.jsonl

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
codexPromptOverlayContext,
GPT5_CONTRACT_MODEL_ID,
NON_GPT5_CONTRACT_MODEL_ID,
sharedGpt5PersonalityConfig,
} from "../../test/helpers/agents/prompt-overlay-runtime-contract.js";
import { buildCodexProvider } from "./provider.js";
describe("Codex prompt overlay runtime contract", () => {
it("adds the shared GPT-5 behavior contract to Codex GPT-5 provider runs", () => {
const provider = buildCodexProvider();
const contribution = provider.resolveSystemPromptContribution?.(
codexPromptOverlayContext({ modelId: GPT5_CONTRACT_MODEL_ID }),
);
expect(contribution?.stablePrefix).toContain("<persona_latch>");
expect(contribution?.sectionOverrides?.interaction_style).toContain(
"This is a live chat, not a memo.",
);
});
it("respects shared GPT-5 prompt overlay config for Codex runs", () => {
const provider = buildCodexProvider();
const contribution = provider.resolveSystemPromptContribution?.(
codexPromptOverlayContext({
modelId: GPT5_CONTRACT_MODEL_ID,
config: sharedGpt5PersonalityConfig("off"),
}),
);
expect(contribution?.stablePrefix).toContain("<persona_latch>");
expect(contribution?.sectionOverrides).toEqual({});
});
it("does not add the shared GPT-5 overlay to non-GPT-5 Codex provider runs", () => {
const provider = buildCodexProvider();
expect(
provider.resolveSystemPromptContribution?.(
codexPromptOverlayContext({ modelId: NON_GPT5_CONTRACT_MODEL_ID }),
),
).toBeUndefined();
});
});

View File

@@ -1,8 +1,55 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { bridgeCodexAppServerStartOptions } from "./auth-bridge.js";
import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyCodexAppServerAuthProfile,
bridgeCodexAppServerStartOptions,
refreshCodexAppServerAuthTokens,
} from "./auth-bridge.js";
const oauthMocks = vi.hoisted(() => ({
refreshOpenAICodexToken: vi.fn(),
}));
const providerRuntimeMocks = vi.hoisted(() => ({
formatProviderAuthProfileApiKeyWithPlugin: vi.fn(),
refreshProviderOAuthCredentialWithPlugin: vi.fn(
async (params: { context: { refresh: string } }) => {
const refreshed = await oauthMocks.refreshOpenAICodexToken(params.context.refresh);
return refreshed
? {
...params.context,
...refreshed,
type: "oauth",
provider: "openai-codex",
}
: undefined;
},
),
}));
vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthApiKey: vi.fn(),
getOAuthProviders: () => [],
loginOpenAICodex: vi.fn(),
refreshOpenAICodexToken: oauthMocks.refreshOpenAICodexToken,
}));
vi.mock("../../../../src/plugins/provider-runtime.runtime.js", () => ({
formatProviderAuthProfileApiKeyWithPlugin:
providerRuntimeMocks.formatProviderAuthProfileApiKeyWithPlugin,
refreshProviderOAuthCredentialWithPlugin:
providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin,
}));
afterEach(() => {
vi.unstubAllEnvs();
oauthMocks.refreshOpenAICodexToken.mockReset();
providerRuntimeMocks.formatProviderAuthProfileApiKeyWithPlugin.mockReset();
providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin.mockClear();
});
describe("bridgeCodexAppServerStartOptions", () => {
it("leaves Codex app-server start options unchanged", async () => {
@@ -30,4 +77,290 @@ describe("bridgeCodexAppServerStartOptions", () => {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("applies an OpenAI Codex OAuth profile through app-server login", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "account-123",
email: "codex@example.test",
},
});
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai-codex:work",
});
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "access-token",
chatgptAccountId: "account-123",
chatgptPlanType: null,
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("refreshes an expired OpenAI Codex OAuth profile before app-server login", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "fresh-access-token",
refresh: "fresh-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-456",
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "oauth",
provider: "openai-codex",
access: "expired-access-token",
refresh: "refresh-token",
expires: Date.now() - 60_000,
accountId: "account-123",
email: "codex@example.test",
},
});
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai-codex:work",
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("refresh-token");
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "fresh-access-token",
chatgptAccountId: "account-456",
chatgptPlanType: null,
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("applies an OpenAI Codex api-key profile backed by a secret ref", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "apiKey" }));
vi.stubEnv("OPENAI_CODEX_API_KEY", "ref-backed-api-key");
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "api_key",
provider: "openai-codex",
keyRef: { source: "env", provider: "default", id: "OPENAI_CODEX_API_KEY" },
},
});
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai-codex:work",
});
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "apiKey",
apiKey: "ref-backed-api-key",
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("applies an OpenAI Codex token profile backed by a secret ref", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
vi.stubEnv("OPENAI_CODEX_TOKEN", "ref-backed-access-token");
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "token",
provider: "openai-codex",
tokenRef: { source: "env", provider: "default", id: "OPENAI_CODEX_TOKEN" },
email: "codex@example.test",
},
});
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai-codex:work",
});
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "ref-backed-access-token",
chatgptAccountId: "codex@example.test",
chatgptPlanType: null,
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("accepts a legacy Codex auth-provider alias for app-server login", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "token",
provider: "codex-cli",
token: "legacy-access-token",
email: "legacy-codex@example.test",
},
});
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai-codex:work",
});
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "legacy-access-token",
chatgptAccountId: "legacy-codex@example.test",
chatgptPlanType: null,
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("answers app-server ChatGPT token refresh requests from the bound profile", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "refreshed-access-token",
refresh: "refreshed-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-789",
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "oauth",
provider: "openai-codex",
access: "stale-access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
accountId: "account-123",
email: "codex@example.test",
},
});
await expect(
refreshCodexAppServerAuthTokens({
agentDir,
authProfileId: "openai-codex:work",
}),
).resolves.toEqual({
accessToken: "refreshed-access-token",
chatgptAccountId: "account-789",
chatgptPlanType: null,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("refresh-token");
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("accepts a refreshed Codex OAuth credential when the stored provider is a legacy alias", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "refreshed-alias-access-token",
refresh: "refreshed-alias-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-alias",
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "oauth",
provider: "codex-cli",
access: "stale-alias-access-token",
refresh: "alias-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-legacy",
email: "legacy-codex@example.test",
},
});
await expect(
refreshCodexAppServerAuthTokens({
agentDir,
authProfileId: "openai-codex:work",
}),
).resolves.toEqual({
accessToken: "refreshed-alias-access-token",
chatgptAccountId: "account-alias",
chatgptPlanType: null,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("alias-refresh-token");
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("preserves a stored ChatGPT plan type when building token login params", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "account-123",
email: "codex@example.test",
chatgptPlanType: "pro",
} as never,
});
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai-codex:work",
});
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "access-token",
chatgptAccountId: "account-123",
chatgptPlanType: "pro",
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
});

View File

@@ -1,4 +1,18 @@
import {
ensureAuthProfileStore,
loadAuthProfileStoreForSecretsRuntime,
resolveProviderIdForAuth,
resolveApiKeyForProfile,
saveAuthProfileStore,
type AuthProfileCredential,
type OAuthCredential,
} from "openclaw/plugin-sdk/agent-runtime";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
import type { ChatgptAuthTokensRefreshResponse } from "./protocol-generated/typescript/v2/ChatgptAuthTokensRefreshResponse.js";
import type { LoginAccountParams } from "./protocol-generated/typescript/v2/LoginAccountParams.js";
const CODEX_APP_SERVER_AUTH_PROVIDER = "openai-codex";
export async function bridgeCodexAppServerStartOptions(params: {
startOptions: CodexAppServerStartOptions;
@@ -9,3 +23,170 @@ export async function bridgeCodexAppServerStartOptions(params: {
void params.authProfileId;
return params.startOptions;
}
export async function applyCodexAppServerAuthProfile(params: {
client: CodexAppServerClient;
agentDir: string;
authProfileId?: string;
}): Promise<void> {
const loginParams = await resolveCodexAppServerAuthProfileLoginParams({
agentDir: params.agentDir,
authProfileId: params.authProfileId,
});
if (!loginParams) {
return;
}
await params.client.request("account/login/start", loginParams);
}
export function resolveCodexAppServerAuthProfileLoginParams(params: {
agentDir: string;
authProfileId?: string;
}): Promise<LoginAccountParams | undefined> {
return resolveCodexAppServerAuthProfileLoginParamsInternal(params);
}
export async function refreshCodexAppServerAuthTokens(params: {
agentDir: string;
authProfileId?: string;
}): Promise<ChatgptAuthTokensRefreshResponse> {
const loginParams = await resolveCodexAppServerAuthProfileLoginParamsInternal({
...params,
forceOAuthRefresh: true,
});
if (!loginParams || loginParams.type !== "chatgptAuthTokens") {
throw new Error("Codex app-server ChatGPT token refresh requires an OAuth auth profile.");
}
return {
accessToken: loginParams.accessToken,
chatgptAccountId: loginParams.chatgptAccountId,
chatgptPlanType: loginParams.chatgptPlanType ?? null,
};
}
async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
agentDir: string;
authProfileId?: string;
forceOAuthRefresh?: boolean;
}): Promise<LoginAccountParams | undefined> {
const profileId = params.authProfileId?.trim();
if (!profileId) {
return undefined;
}
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
const credential = store.profiles[profileId];
if (!credential) {
throw new Error(`Codex app-server auth profile "${profileId}" was not found.`);
}
if (!isCodexAppServerAuthProvider(credential.provider)) {
throw new Error(
`Codex app-server auth profile "${profileId}" must belong to provider "openai-codex" or a supported alias.`,
);
}
const loginParams = await resolveLoginParamsForCredential(profileId, credential, {
agentDir: params.agentDir,
forceOAuthRefresh: params.forceOAuthRefresh === true,
});
if (!loginParams) {
throw new Error(
`Codex app-server auth profile "${profileId}" does not contain usable credentials.`,
);
}
return loginParams;
}
async function resolveLoginParamsForCredential(
profileId: string,
credential: AuthProfileCredential,
params: { agentDir: string; forceOAuthRefresh: boolean },
): Promise<LoginAccountParams | undefined> {
if (credential.type === "api_key") {
const resolved = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }),
profileId,
agentDir: params.agentDir,
});
const apiKey = resolved?.apiKey?.trim();
return apiKey ? { type: "apiKey", apiKey } : undefined;
}
if (credential.type === "token") {
const resolved = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }),
profileId,
agentDir: params.agentDir,
});
const accessToken = resolved?.apiKey?.trim();
return accessToken
? buildChatgptAuthTokensParams(profileId, credential, accessToken)
: undefined;
}
const resolvedCredential = await resolveOAuthCredentialForCodexAppServer(profileId, credential, {
agentDir: params.agentDir,
forceRefresh: params.forceOAuthRefresh,
});
const accessToken = resolvedCredential.access?.trim();
return accessToken
? buildChatgptAuthTokensParams(profileId, resolvedCredential, accessToken)
: undefined;
}
async function resolveOAuthCredentialForCodexAppServer(
profileId: string,
credential: OAuthCredential,
params: { agentDir: string; forceRefresh: boolean },
): Promise<OAuthCredential> {
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
if (params.forceRefresh) {
store.profiles[profileId] = { ...credential, expires: 0 };
saveAuthProfileStore(store, params.agentDir);
}
const resolved = await resolveApiKeyForProfile({
store,
profileId,
agentDir: params.agentDir,
});
const refreshed = loadAuthProfileStoreForSecretsRuntime(params.agentDir).profiles[profileId];
const storedCredential = store.profiles[profileId];
const candidate =
refreshed?.type === "oauth" && isCodexAppServerAuthProvider(refreshed.provider)
? refreshed
: storedCredential?.type === "oauth" &&
isCodexAppServerAuthProvider(storedCredential.provider)
? storedCredential
: credential;
return resolved?.apiKey ? { ...candidate, access: resolved.apiKey } : candidate;
}
function isCodexAppServerAuthProvider(provider: string): boolean {
return resolveProviderIdForAuth(provider) === CODEX_APP_SERVER_AUTH_PROVIDER;
}
function buildChatgptAuthTokensParams(
profileId: string,
credential: AuthProfileCredential,
accessToken: string,
): LoginAccountParams {
return {
type: "chatgptAuthTokens",
accessToken,
chatgptAccountId: resolveChatgptAccountId(profileId, credential),
chatgptPlanType: resolveChatgptPlanType(credential),
};
}
function resolveChatgptPlanType(credential: AuthProfileCredential): string | null {
const record = credential as Record<string, unknown>;
const planType = record.chatgptPlanType ?? record.planType;
return typeof planType === "string" && planType.trim() ? planType.trim() : null;
}
function resolveChatgptAccountId(profileId: string, credential: AuthProfileCredential): string {
if ("accountId" in credential && typeof credential.accountId === "string") {
const accountId = credential.accountId.trim();
if (accountId) {
return accountId;
}
}
const email = credential.email?.trim();
return email || profileId;
}

View File

@@ -0,0 +1,210 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
abortAgentHarnessRun,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AUTH_PROFILE_RUNTIME_CONTRACT } from "../../../../test/helpers/agents/auth-profile-runtime-contract.js";
import { runCodexAppServerAttempt, __testing } from "./run-attempt.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import { createCodexTestModel } from "./test-support.js";
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
return {
prompt: AUTH_PROFILE_RUNTIME_CONTRACT.workspacePrompt,
sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
sessionKey: AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey,
sessionFile,
workspaceDir,
runId: AUTH_PROFILE_RUNTIME_CONTRACT.runId,
provider: AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider,
modelId: "gpt-5.4-codex",
model: createCodexTestModel(AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider),
thinkLevel: "medium",
disableTools: true,
timeoutMs: 5_000,
authStorage: {} as never,
modelRegistry: {} as never,
} as EmbeddedRunAttemptParams;
}
function threadStartResult(threadId = "thread-auth-contract") {
return {
thread: {
id: threadId,
forkedFromId: null,
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: "",
cliVersion: "0.118.0",
source: "unknown",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
},
model: "gpt-5.4-codex",
modelProvider: "openai",
serviceTier: null,
cwd: "",
instructionSources: [],
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "dangerFullAccess" },
permissionProfile: null,
reasoningEffort: null,
};
}
function turnStartResult(turnId = "turn-auth-contract") {
return {
turn: {
id: turnId,
status: "inProgress",
items: [],
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
},
};
}
function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "thread/resume" }) {
const seenAuthProfileIds: Array<string | undefined> = [];
const requests: Array<{ method: string; params: unknown }> = [];
let notify: (notification: unknown) => Promise<void> = async () => undefined;
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
seenAuthProfileIds.push(authProfileId);
return {
request: vi.fn(async (method: string, requestParams?: unknown) => {
requests.push({ method, params: requestParams });
if (method === params.startMethod) {
return threadStartResult();
}
if (method === "turn/start") {
return turnStartResult();
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: (handler: (notification: unknown) => Promise<void>) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
});
return {
seenAuthProfileIds,
async waitForMethod(method: string) {
await vi.waitFor(() => expect(requests.some((entry) => entry.method === method)).toBe(true), {
interval: 1,
});
},
async completeTurn() {
await notify({
method: "turn/completed",
params: {
threadId: "thread-auth-contract",
turnId: "turn-auth-contract",
turn: { id: "turn-auth-contract", status: "completed" },
},
});
},
};
}
describe("Auth profile runtime contract - Codex app-server adapter", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-auth-contract-"));
});
afterEach(async () => {
abortAgentHarnessRun(AUTH_PROFILE_RUNTIME_CONTRACT.sessionId);
__testing.resetCodexAppServerClientFactoryForTests();
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("passes the exact OpenAI Codex auth profile into app-server startup", async () => {
const harness = createCodexAuthProfileHarness({ startMethod: "thread/start" });
const sessionFile = path.join(tmpDir, "session.jsonl");
const params = createParams(sessionFile, tmpDir);
params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId;
const run = runCodexAppServerAttempt(params);
await vi.waitFor(
() =>
expect(harness.seenAuthProfileIds).toEqual([
AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
]),
{ interval: 1 },
);
await harness.waitForMethod("turn/start");
await harness.completeTurn();
await run;
});
it("reuses a bound OpenAI Codex auth profile when resume params omit authProfileId", async () => {
const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" });
const sessionFile = path.join(tmpDir, "session.jsonl");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-auth-contract",
cwd: tmpDir,
authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
dynamicToolsFingerprint: "[]",
});
// authProfileId is intentionally omitted to exercise the resume-bound profile path.
const params = createParams(sessionFile, tmpDir);
const run = runCodexAppServerAttempt(params);
await vi.waitFor(
() =>
expect(harness.seenAuthProfileIds).toEqual([
AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
]),
{ interval: 1 },
);
await harness.waitForMethod("turn/start");
await harness.completeTurn();
await run;
});
it("prefers an explicit runtime auth profile over a stale persisted binding", async () => {
const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" });
const sessionFile = path.join(tmpDir, "session.jsonl");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-auth-contract",
cwd: tmpDir,
authProfileId: "openai-codex:stale",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, tmpDir);
params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId;
const run = runCodexAppServerAttempt(params);
await vi.waitFor(
() =>
expect(harness.seenAuthProfileIds).toEqual([
AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
]),
{ interval: 1 },
);
await harness.waitForMethod("turn/start");
await harness.completeTurn();
await run;
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
});
});
});

View File

@@ -167,7 +167,10 @@ export function resolveCodexAppServerRuntimeOptions(
};
}
export function codexAppServerStartOptionsKey(options: CodexAppServerStartOptions): string {
export function codexAppServerStartOptionsKey(
options: CodexAppServerStartOptions,
params: { authProfileId?: string } = {},
): string {
return JSON.stringify({
transport: options.transport,
command: options.command,
@@ -179,6 +182,7 @@ export function codexAppServerStartOptionsKey(options: CodexAppServerStartOption
),
env: Object.entries(options.env ?? {}).toSorted(([left], [right]) => left.localeCompare(right)),
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
authProfileId: params.authProfileId ?? null,
});
}

View File

@@ -0,0 +1,80 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
import { afterEach, describe, expect, it } from "vitest";
import { isSilentReplyPayloadText } from "../../../../src/auto-reply/tokens.js";
import { DELIVERY_NO_REPLY_RUNTIME_CONTRACT } from "../../../../test/helpers/agents/delivery-no-reply-runtime-contract.js";
import { CodexAppServerEventProjector } from "./event-projector.js";
import { createCodexTestModel } from "./test-support.js";
const THREAD_ID = "thread-delivery-contract";
const TURN_ID = "turn-delivery-contract";
const tempDirs = new Set<string>();
type ProjectorNotification = Parameters<CodexAppServerEventProjector["handleNotification"]>[0];
async function createParams(): Promise<EmbeddedRunAttemptParams> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-delivery-contract-"));
tempDirs.add(tempDir);
const sessionFile = path.join(tempDir, "session.jsonl");
SessionManager.open(sessionFile);
return {
prompt: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.prompt,
sessionId: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.sessionId,
sessionKey: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.sessionKey,
sessionFile,
workspaceDir: tempDir,
runId: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.runId,
provider: "codex",
modelId: "gpt-5.4-codex",
model: createCodexTestModel("codex"),
thinkLevel: "medium",
} as EmbeddedRunAttemptParams;
}
function forCurrentTurn(
method: ProjectorNotification["method"],
params: Record<string, unknown>,
): ProjectorNotification {
return {
method,
params: { threadId: THREAD_ID, turnId: TURN_ID, ...params },
} as ProjectorNotification;
}
afterEach(async () => {
for (const tempDir of tempDirs) {
await fs.rm(tempDir, { recursive: true, force: true });
}
tempDirs.clear();
});
describe("Delivery/NO_REPLY runtime contract - Codex app-server adapter", () => {
it.each([
DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText,
` ${DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText} `,
DELIVERY_NO_REPLY_RUNTIME_CONTRACT.jsonSilentText,
])("preserves silent terminal text %s for shared delivery suppression", async (text) => {
const projector = new CodexAppServerEventProjector(await createParams(), THREAD_ID, TURN_ID);
await projector.handleNotification(
forCurrentTurn("item/agentMessage/delta", {
itemId: "msg-1",
delta: text,
}),
);
const result = projector.buildResult({
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
toolMediaUrls: [],
toolAudioAsVoice: false,
});
expect(result.assistantTexts).toEqual([text.trim()]);
expect(isSilentReplyPayloadText(result.assistantTexts[0])).toBe(true);
});
});

View File

@@ -34,6 +34,10 @@ export type CodexAppServerToolTelemetry = {
successfulCronAdds?: number;
};
type AgentHarnessResultClassification = NonNullable<
EmbeddedRunAttemptResult["agentHarnessResultClassification"]
>;
const ZERO_USAGE: Usage = {
input: 0,
output: 0,
@@ -60,6 +64,25 @@ const CURRENT_TOKEN_USAGE_KEYS = [
const MAX_TOOL_OUTPUT_DELTA_MESSAGES_PER_ITEM = 20;
function classifyTerminalResult(params: {
assistantTexts: string[];
reasoningText: string;
planText: string;
promptError: unknown;
turnCompleted: boolean;
}): AgentHarnessResultClassification | undefined {
if (!params.turnCompleted || params.promptError || params.assistantTexts.length > 0) {
return undefined;
}
if (params.planText.trim()) {
return "planning-only";
}
if (params.reasoningText.trim()) {
return "reasoning-only";
}
return "empty";
}
export class CodexAppServerEventProjector {
private readonly assistantTextByItem = new Map<string, string>();
private readonly assistantItemOrder: string[] = [];
@@ -192,6 +215,13 @@ export class CodexAppServerEventProjector {
const promptError =
this.promptError ??
(turnFailed ? (this.completedTurn?.error?.message ?? "codex app-server turn failed") : null);
const agentHarnessResultClassification = classifyTerminalResult({
assistantTexts,
reasoningText,
planText,
promptError,
turnCompleted: Boolean(this.completedTurn),
});
return {
aborted: this.aborted || turnInterrupted,
externalAbort: false,
@@ -201,6 +231,7 @@ export class CodexAppServerEventProjector {
promptError,
promptErrorSource: promptError ? this.promptErrorSource || "prompt" : null,
sessionIdUsed: this.params.sessionId,
...(agentHarnessResultClassification ? { agentHarnessResultClassification } : {}),
bootstrapPromptWarningSignaturesSeen: this.params.bootstrapPromptWarningSignaturesSeen,
bootstrapPromptWarningSignature: this.params.bootstrapPromptWarningSignature,
messagesSnapshot,

View File

@@ -4,6 +4,7 @@ import { createClientHarness } from "./test-support.js";
const mocks = vi.hoisted(() => {
const authBridge = {
applyAuthProfile: vi.fn(async () => undefined),
startOptions: vi.fn(async ({ startOptions }) => startOptions),
};
const providerAuth = {
@@ -13,6 +14,7 @@ const mocks = vi.hoisted(() => {
});
vi.mock("./auth-bridge.js", () => ({
applyCodexAppServerAuthProfile: mocks.authBridge.applyAuthProfile,
bridgeCodexAppServerStartOptions: mocks.authBridge.startOptions,
}));
@@ -34,6 +36,7 @@ describe("listCodexAppServerModels", () => {
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.restoreAllMocks();
mocks.authBridge.applyAuthProfile.mockClear();
mocks.authBridge.startOptions.mockClear();
mocks.providerAuth.agentDir.mockClear();
});

View File

@@ -0,0 +1,408 @@
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness";
import { afterEach, describe, expect, it, vi } from "vitest";
import { wrapToolWithBeforeToolCallHook } from "../../../../src/agents/pi-tools.before-tool-call.js";
import {
installCodexToolResultMiddleware,
installOpenClawOwnedToolHooks,
mediaToolResult,
resetOpenClawOwnedToolHooks,
textToolResult,
} from "../../../../test/helpers/agents/openclaw-owned-tool-runtime-contract.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
function createContractTool(overrides: Partial<AnyAgentTool>): AnyAgentTool {
return {
name: "exec",
description: "Run a command.",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
...overrides,
} as unknown as AnyAgentTool;
}
describe("OpenClaw-owned tool runtime contract — Codex app-server adapter", () => {
afterEach(() => {
resetOpenClawOwnedToolHooks();
});
it("wraps unwrapped dynamic tools with before/after tool hooks", async () => {
const adjustedParams = { mode: "safe" };
const mergedParams = { command: "pwd", mode: "safe" };
const hooks = installOpenClawOwnedToolHooks({ adjustedParams });
const execute = vi.fn(async () => textToolResult("done", { ok: true }));
const bridge = createCodexDynamicToolBridge({
tools: [createContractTool({ name: "exec", execute })],
signal: new AbortController().signal,
hookContext: {
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-contract",
},
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-contract",
namespace: null,
tool: "exec",
arguments: { command: "pwd" },
});
expect(result).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "done" }],
});
expect(hooks.beforeToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "exec",
toolCallId: "call-contract",
runId: "run-contract",
params: { command: "pwd" },
}),
expect.objectContaining({
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-contract",
toolCallId: "call-contract",
}),
);
expect(execute).toHaveBeenCalledWith(
"call-contract",
mergedParams,
expect.any(AbortSignal),
undefined,
);
await vi.waitFor(() => {
expect(hooks.afterToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "exec",
toolCallId: "call-contract",
params: mergedParams,
result: expect.objectContaining({
content: [{ type: "text", text: "done" }],
details: { ok: true },
}),
}),
expect.objectContaining({
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-contract",
toolCallId: "call-contract",
}),
);
});
});
it("runs tool_result middleware before after_tool_call observes the result", async () => {
const adjustedParams = { mode: "safe" };
const mergedParams = { command: "status", mode: "safe" };
const hooks = installOpenClawOwnedToolHooks({ adjustedParams });
const middleware = installCodexToolResultMiddleware((event) => {
expect(event).toMatchObject({
toolName: "exec",
toolCallId: "call-middleware",
args: { command: "status" },
result: {
content: [{ type: "text", text: "raw output" }],
details: { stage: "execute" },
},
});
return textToolResult("compacted output", { stage: "middleware" });
});
const execute = vi.fn(async () => textToolResult("raw output", { stage: "execute" }));
const bridge = createCodexDynamicToolBridge({
tools: [createContractTool({ name: "exec", execute })],
signal: new AbortController().signal,
hookContext: {
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-middleware",
},
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-middleware",
namespace: null,
tool: "exec",
arguments: { command: "status" },
});
expect(result).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "compacted output" }],
});
expect(execute).toHaveBeenCalledWith(
"call-middleware",
mergedParams,
expect.any(AbortSignal),
undefined,
);
expect(middleware.middleware).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(hooks.afterToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "exec",
toolCallId: "call-middleware",
params: mergedParams,
result: expect.objectContaining({
content: [{ type: "text", text: "compacted output" }],
details: { stage: "middleware" },
}),
}),
expect.objectContaining({
runId: "run-middleware",
toolCallId: "call-middleware",
}),
);
});
});
it("fails closed when before_tool_call blocks a dynamic tool", async () => {
const hooks = installOpenClawOwnedToolHooks({ blockReason: "blocked by policy" });
const execute = vi.fn(async () => textToolResult("should not run"));
const bridge = createCodexDynamicToolBridge({
tools: [createContractTool({ name: "message", execute })],
signal: new AbortController().signal,
hookContext: { runId: "run-blocked" },
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-blocked",
namespace: null,
tool: "message",
arguments: {
action: "send",
text: "blocked",
provider: "telegram",
to: "chat-1",
},
});
expect(result).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "blocked by policy" }],
});
expect(execute).not.toHaveBeenCalled();
expect(bridge.telemetry.didSendViaMessagingTool).toBe(false);
await vi.waitFor(() => {
expect(hooks.afterToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "message",
toolCallId: "call-blocked",
params: {
action: "send",
text: "blocked",
provider: "telegram",
to: "chat-1",
},
error: "blocked by policy",
}),
expect.objectContaining({
runId: "run-blocked",
toolCallId: "call-blocked",
}),
);
});
});
it("reports dynamic tool execution errors through after_tool_call", async () => {
const adjustedParams = { timeoutSec: 1 };
const mergedParams = { command: "false", timeoutSec: 1 };
const hooks = installOpenClawOwnedToolHooks({ adjustedParams });
const execute = vi.fn(async () => {
throw new Error("tool failed");
});
const bridge = createCodexDynamicToolBridge({
tools: [createContractTool({ name: "exec", execute })],
signal: new AbortController().signal,
hookContext: { runId: "run-error" },
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-error",
namespace: null,
tool: "exec",
arguments: { command: "false" },
});
expect(result).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "tool failed" }],
});
expect(execute).toHaveBeenCalledWith(
"call-error",
mergedParams,
expect.any(AbortSignal),
undefined,
);
await vi.waitFor(() => {
expect(hooks.afterToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "exec",
toolCallId: "call-error",
params: mergedParams,
error: "tool failed",
}),
expect.objectContaining({
runId: "run-error",
toolCallId: "call-error",
}),
);
});
});
it("records successful Codex messaging text, media, and target telemetry", async () => {
const hooks = installOpenClawOwnedToolHooks();
const execute = vi.fn(async () => textToolResult("Sent."));
const bridge = createCodexDynamicToolBridge({
tools: [createContractTool({ name: "message", execute })],
signal: new AbortController().signal,
hookContext: { runId: "run-message" },
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-message",
namespace: null,
tool: "message",
arguments: {
action: "send",
text: "hello from Codex",
mediaUrl: "/tmp/codex-reply.png",
provider: "telegram",
to: "chat-1",
threadId: "thread-ts-1",
},
});
expect(result).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "Sent." }],
});
expect(bridge.telemetry).toMatchObject({
didSendViaMessagingTool: true,
messagingToolSentTexts: ["hello from Codex"],
messagingToolSentMediaUrls: ["/tmp/codex-reply.png"],
messagingToolSentTargets: [
{
tool: "message",
provider: "telegram",
to: "chat-1",
threadId: "thread-ts-1",
},
],
});
await vi.waitFor(() => {
expect(hooks.afterToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "message",
toolCallId: "call-message",
params: expect.objectContaining({
text: "hello from Codex",
mediaUrl: "/tmp/codex-reply.png",
}),
}),
expect.objectContaining({
runId: "run-message",
toolCallId: "call-message",
}),
);
});
});
it("records successful Codex media artifacts from tool results", async () => {
const hooks = installOpenClawOwnedToolHooks();
const execute = vi.fn(async () =>
mediaToolResult("Generated media reply.", "/tmp/reply.opus", true),
);
const bridge = createCodexDynamicToolBridge({
tools: [createContractTool({ name: "tts", execute })],
signal: new AbortController().signal,
hookContext: { runId: "run-media" },
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-media",
namespace: null,
tool: "tts",
arguments: { text: "hello" },
});
expect(result).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "Generated media reply." }],
});
expect(bridge.telemetry.toolMediaUrls).toEqual(["/tmp/reply.opus"]);
expect(bridge.telemetry.toolAudioAsVoice).toBe(true);
await vi.waitFor(() => {
expect(hooks.afterToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "tts",
toolCallId: "call-media",
result: expect.objectContaining({
details: {
media: {
mediaUrl: "/tmp/reply.opus",
audioAsVoice: true,
},
},
}),
}),
expect.objectContaining({
runId: "run-media",
toolCallId: "call-media",
}),
);
});
});
it("does not double-wrap dynamic tools that already have before_tool_call", async () => {
const adjustedParams = { mode: "safe" };
const mergedParams = { command: "pwd", mode: "safe" };
const hooks = installOpenClawOwnedToolHooks({ adjustedParams });
const execute = vi.fn(async () => textToolResult("done"));
const tool = wrapToolWithBeforeToolCallHook(createContractTool({ name: "exec", execute }), {
runId: "run-wrapped",
});
const bridge = createCodexDynamicToolBridge({
tools: [tool],
signal: new AbortController().signal,
hookContext: { runId: "run-wrapped" },
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-wrapped",
namespace: null,
tool: "exec",
arguments: { command: "pwd" },
});
expect(result).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "done" }],
});
expect(hooks.beforeToolCall).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith(
"call-wrapped",
mergedParams,
expect.any(AbortSignal),
undefined,
);
});
});

View File

@@ -0,0 +1,352 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
import { afterEach, describe, expect, it } from "vitest";
import { classifyEmbeddedPiRunResultForModelFallback } from "../../../../src/agents/pi-embedded-runner/result-fallback-classifier.js";
import {
createContractRunResult,
OUTCOME_FALLBACK_RUNTIME_CONTRACT,
} from "../../../../test/helpers/agents/outcome-fallback-runtime-contract.js";
import {
CodexAppServerEventProjector,
type CodexAppServerToolTelemetry,
} from "./event-projector.js";
import { createCodexTestModel } from "./test-support.js";
const THREAD_ID = "thread-outcome-contract";
const TURN_ID = "turn-outcome-contract";
const tempDirs = new Set<string>();
type ProjectorNotification = Parameters<CodexAppServerEventProjector["handleNotification"]>[0];
type ProjectedAttemptResult = ReturnType<CodexAppServerEventProjector["buildResult"]>;
async function createParams(): Promise<EmbeddedRunAttemptParams> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-outcome-contract-"));
tempDirs.add(tempDir);
const sessionFile = path.join(tempDir, "session.jsonl");
SessionManager.open(sessionFile);
return {
prompt: OUTCOME_FALLBACK_RUNTIME_CONTRACT.prompt,
sessionId: OUTCOME_FALLBACK_RUNTIME_CONTRACT.sessionId,
sessionKey: OUTCOME_FALLBACK_RUNTIME_CONTRACT.sessionKey,
sessionFile,
workspaceDir: tempDir,
runId: OUTCOME_FALLBACK_RUNTIME_CONTRACT.runId,
provider: "codex",
modelId: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
model: createCodexTestModel("codex"),
thinkLevel: "medium",
} as EmbeddedRunAttemptParams;
}
async function createProjector(): Promise<CodexAppServerEventProjector> {
return new CodexAppServerEventProjector(await createParams(), THREAD_ID, TURN_ID);
}
function buildToolTelemetry(
overrides: Partial<CodexAppServerToolTelemetry> = {},
): CodexAppServerToolTelemetry {
return {
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
toolMediaUrls: [],
toolAudioAsVoice: false,
...overrides,
};
}
function forCurrentTurn(
method: ProjectorNotification["method"],
params: Record<string, unknown>,
): ProjectorNotification {
return {
method,
params: { threadId: THREAD_ID, turnId: TURN_ID, ...params },
} as ProjectorNotification;
}
function classifyProjectedAttemptResult(result: ProjectedAttemptResult) {
const finalAssistantText = result.assistantTexts.join("\n\n").trim();
return classifyEmbeddedPiRunResultForModelFallback({
provider: "codex",
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
result: createContractRunResult({
...result,
meta: {
durationMs: 1,
aborted: result.aborted,
agentHarnessResultClassification: result.agentHarnessResultClassification,
finalAssistantRawText: finalAssistantText || undefined,
finalAssistantVisibleText: finalAssistantText || undefined,
},
}),
});
}
afterEach(async () => {
for (const tempDir of tempDirs) {
await fs.rm(tempDir, { recursive: true, force: true });
}
tempDirs.clear();
});
describe("Outcome/fallback runtime contract - Codex app-server adapter", () => {
it("preserves an empty terminal turn for OpenClaw-owned fallback classification", async () => {
const projector = await createProjector();
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: { id: TURN_ID, status: "completed", items: [] },
}),
);
const result = projector.buildResult(buildToolTelemetry());
expect(result.assistantTexts).toEqual([]);
expect(result.lastAssistant).toBeUndefined();
expect(result.promptError).toBeNull();
});
it("preserves exact NO_REPLY as assistant text instead of classifying in the adapter", async () => {
const projector = await createProjector();
await projector.handleNotification(
forCurrentTurn("item/agentMessage/delta", {
itemId: "msg-1",
delta: "NO_REPLY",
}),
);
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: {
id: TURN_ID,
status: "completed",
items: [{ type: "agentMessage", id: "msg-1", text: "NO_REPLY" }],
},
}),
);
const result = projector.buildResult(buildToolTelemetry());
expect(result.assistantTexts).toEqual(["NO_REPLY"]);
expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "NO_REPLY" }]);
expect(result.promptError).toBeNull();
});
it("preserves reasoning-only terminal turns for OpenClaw-owned fallback classification", async () => {
const projector = await createProjector();
await projector.handleNotification(
forCurrentTurn("item/reasoning/textDelta", {
itemId: "reasoning-1",
delta: OUTCOME_FALLBACK_RUNTIME_CONTRACT.reasoningOnlyText,
}),
);
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: {
id: TURN_ID,
status: "completed",
items: [{ type: "reasoning", id: "reasoning-1" }],
},
}),
);
const result = projector.buildResult(buildToolTelemetry());
expect(result.assistantTexts).toEqual([]);
expect(result.lastAssistant).toBeUndefined();
expect(result.promptError).toBeNull();
expect(result.messagesSnapshot).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: [
{
type: "text",
text: `Codex reasoning:\n${OUTCOME_FALLBACK_RUNTIME_CONTRACT.reasoningOnlyText}`,
},
],
}),
]),
);
});
it("preserves planning-only terminal turns for OpenClaw-owned fallback classification", async () => {
const projector = await createProjector();
await projector.handleNotification(
forCurrentTurn("item/plan/delta", {
itemId: "plan-1",
delta: OUTCOME_FALLBACK_RUNTIME_CONTRACT.planningOnlyText,
}),
);
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: {
id: TURN_ID,
status: "completed",
items: [
{
type: "plan",
id: "plan-1",
text: OUTCOME_FALLBACK_RUNTIME_CONTRACT.planningOnlyText,
},
],
},
}),
);
const result = projector.buildResult(buildToolTelemetry());
expect(result.assistantTexts).toEqual([]);
expect(result.lastAssistant).toBeUndefined();
expect(result.promptError).toBeNull();
expect(result.messagesSnapshot).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: [
{
type: "text",
text: `Codex plan:\n${OUTCOME_FALLBACK_RUNTIME_CONTRACT.planningOnlyText}`,
},
],
}),
]),
);
});
it("preserves tool side-effect telemetry so fallback can stay disabled", async () => {
const projector = await createProjector();
const result = projector.buildResult(
buildToolTelemetry({
didSendViaMessagingTool: true,
messagingToolSentTexts: ["sent out of band"],
}),
);
expect(result.assistantTexts).toEqual([]);
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.messagingToolSentTexts).toEqual(["sent out of band"]);
});
it.each([
{
name: "empty",
classification: "empty",
expectedCode: "empty_result",
build: async () => {
const projector = await createProjector();
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: { id: TURN_ID, status: "completed", items: [] },
}),
);
return projector.buildResult(buildToolTelemetry());
},
},
{
name: "reasoning-only",
classification: "reasoning-only",
expectedCode: "reasoning_only_result",
build: async () => {
const projector = await createProjector();
await projector.handleNotification(
forCurrentTurn("item/reasoning/textDelta", {
itemId: "reasoning-1",
delta: OUTCOME_FALLBACK_RUNTIME_CONTRACT.reasoningOnlyText,
}),
);
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: {
id: TURN_ID,
status: "completed",
items: [{ type: "reasoning", id: "reasoning-1" }],
},
}),
);
return projector.buildResult(buildToolTelemetry());
},
},
{
name: "planning-only",
classification: "planning-only",
expectedCode: "planning_only_result",
build: async () => {
const projector = await createProjector();
await projector.handleNotification(
forCurrentTurn("item/plan/delta", {
itemId: "plan-1",
delta: OUTCOME_FALLBACK_RUNTIME_CONTRACT.planningOnlyText,
}),
);
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: {
id: TURN_ID,
status: "completed",
items: [
{
type: "plan",
id: "plan-1",
text: OUTCOME_FALLBACK_RUNTIME_CONTRACT.planningOnlyText,
},
],
},
}),
);
return projector.buildResult(buildToolTelemetry());
},
},
] as const)(
"keeps $name terminal turns fallback-ready with adapter-produced classification",
async ({ build, classification, expectedCode }) => {
const result = await build();
expect(result.agentHarnessResultClassification).toBe(classification);
expect(classifyProjectedAttemptResult(result)).toMatchObject({
reason: "format",
code: expectedCode,
});
},
);
it("keeps exact NO_REPLY classified as an intentional silent terminal reply", async () => {
const projector = await createProjector();
await projector.handleNotification(
forCurrentTurn("item/agentMessage/delta", {
itemId: "msg-1",
delta: "NO_REPLY",
}),
);
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: {
id: TURN_ID,
status: "completed",
items: [{ type: "agentMessage", id: "msg-1", text: "NO_REPLY" }],
},
}),
);
const result = projector.buildResult(buildToolTelemetry());
expect(classifyProjectedAttemptResult(result)).toBeNull();
});
it("keeps tool side effects classified as non-fallback terminal outcomes", async () => {
const projector = await createProjector();
const result = projector.buildResult(
buildToolTelemetry({
didSendViaMessagingTool: true,
messagingToolSentTexts: ["sent out of band"],
}),
);
expect(result.agentHarnessResultClassification).toBeUndefined();
expect(classifyProjectedAttemptResult(result)).toBeNull();
});
});

View File

@@ -34,6 +34,7 @@ import {
type NativeHookRelayRegistrationHandle,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
import { refreshCodexAppServerAuthTokens } from "./auth-bridge.js";
import {
createCodexAppServerClientFactoryTestHooks,
defaultCodexAppServerClientFactory,
@@ -149,7 +150,10 @@ export async function runCodexAppServerAttempt(
: undefined;
let yieldDetected = false;
const startupBinding = await readCodexAppServerBinding(params.sessionFile);
const startupAuthProfileId = params.authProfileId ?? startupBinding?.authProfileId;
const startupAuthProfileId =
params.runtimePlan?.auth.forwardedAuthProfileId ??
params.authProfileId ??
startupBinding?.authProfileId;
const tools = await buildDynamicTools({
params,
resolvedWorkspace,
@@ -373,6 +377,12 @@ export async function runCodexAppServerAttempt(
const notificationCleanup = client.addNotificationHandler(enqueueNotification);
const requestCleanup = client.addRequestHandler(async (request) => {
if (request.method === "account/chatgptAuthTokens/refresh") {
return refreshCodexAppServerAuthTokens({
agentDir,
authProfileId: startupAuthProfileId,
});
}
if (!turnId) {
return undefined;
}
@@ -486,7 +496,11 @@ export async function runCodexAppServerAttempt(
sessionId: params.sessionId,
provider: params.provider,
model: params.modelId,
resolvedRef: `${params.provider}/${params.modelId}`,
resolvedRef:
params.runtimePlan?.observability.resolvedRef ?? `${params.provider}/${params.modelId}`,
...(params.runtimePlan?.observability.harnessId
? { harnessId: params.runtimePlan.observability.harnessId }
: {}),
assistantTexts: [],
},
ctx: hookContext,
@@ -642,7 +656,11 @@ export async function runCodexAppServerAttempt(
sessionId: params.sessionId,
provider: params.provider,
model: params.modelId,
resolvedRef: `${params.provider}/${params.modelId}`,
resolvedRef:
params.runtimePlan?.observability.resolvedRef ?? `${params.provider}/${params.modelId}`,
...(params.runtimePlan?.observability.harnessId
? { harnessId: params.runtimePlan.observability.harnessId }
: {}),
assistantTexts: result.assistantTexts,
...(result.lastAssistant ? { lastAssistant: result.lastAssistant } : {}),
...(result.attemptUsage ? { usage: result.attemptUsage } : {}),
@@ -821,16 +839,23 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
params.toolsAllow && params.toolsAllow.length > 0
? visionFilteredTools.filter((tool) => params.toolsAllow?.includes(tool.name))
: visionFilteredTools;
return normalizeProviderToolSchemas({
tools: filteredTools,
provider: params.provider,
config: params.config,
workspaceDir: input.effectiveWorkspace,
env: process.env,
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
});
return (
params.runtimePlan?.tools.normalize(filteredTools, {
workspaceDir: input.effectiveWorkspace,
modelApi: params.model.api,
model: params.model,
}) ??
normalizeProviderToolSchemas({
tools: filteredTools,
provider: params.provider,
config: params.config,
workspaceDir: input.effectiveWorkspace,
env: process.env,
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
})
);
}
async function withCodexStartupTimeout<T>(params: {

View File

@@ -0,0 +1,168 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createParameterFreeTool,
createPermissiveTool,
normalizedParameterFreeSchema,
} from "../../../../test/helpers/agents/schema-normalization-runtime-contract.js";
import { createCodexTestModel } from "./test-support.js";
import { startOrResumeThread } from "./thread-lifecycle.js";
let tempDir: string;
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
return {
prompt: "hello",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir,
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: createCodexTestModel("codex"),
thinkLevel: "medium",
disableTools: true,
timeoutMs: 5_000,
authStorage: {} as never,
modelRegistry: {} as never,
} as EmbeddedRunAttemptParams;
}
function createAppServerOptions(): Parameters<typeof startOrResumeThread>[0]["appServer"] {
return {
start: {
transport: "stdio",
command: "codex",
args: ["app-server"],
headers: {},
},
requestTimeoutMs: 60_000,
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "workspace-write",
};
}
function threadStartResult(threadId = "thread-1") {
return {
thread: {
id: threadId,
forkedFromId: null,
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: tempDir,
cliVersion: "0.118.0",
source: "unknown",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
},
model: "gpt-5.4",
modelProvider: "openai",
serviceTier: null,
cwd: tempDir,
instructionSources: [],
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "dangerFullAccess" },
permissionProfile: null,
reasoningEffort: null,
};
}
describe("Codex app-server dynamic tool schema boundary contract", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-schema-contract-"));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("passes prepared executable dynamic tool schemas through thread start unchanged", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const parameterFreeTool = createParameterFreeTool("message");
const dynamicTool = {
name: parameterFreeTool.name,
description: parameterFreeTool.description,
inputSchema: normalizedParameterFreeSchema(),
};
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult();
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [dynamicTool],
appServer: createAppServerOptions(),
});
expect(request).toHaveBeenCalledWith(
"thread/start",
expect.objectContaining({
dynamicTools: [dynamicTool],
}),
);
});
it("treats dynamic tool schema changes as thread-fingerprint changes", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const appServer = createAppServerOptions();
let nextThreadId = 1;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult(`thread-${nextThreadId++}`);
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [
{
name: "message",
description: "Permissive test tool",
inputSchema: { type: "object" },
},
],
appServer,
});
const permissiveTool = createPermissiveTool("message");
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [
{
name: permissiveTool.name,
description: permissiveTool.description,
inputSchema: permissiveTool.parameters,
},
],
appServer,
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
});
});

View File

@@ -5,10 +5,12 @@ import { createClientHarness } from "./test-support.js";
const mocks = vi.hoisted(() => ({
bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions),
applyCodexAppServerAuthProfile: vi.fn(async () => undefined),
resolveOpenClawAgentDir: vi.fn(() => "/tmp/openclaw-agent"),
}));
vi.mock("./auth-bridge.js", () => ({
applyCodexAppServerAuthProfile: mocks.applyCodexAppServerAuthProfile,
bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions,
}));
@@ -51,6 +53,7 @@ describe("shared Codex app-server client", () => {
vi.useRealTimers();
vi.restoreAllMocks();
mocks.bridgeCodexAppServerStartOptions.mockClear();
mocks.applyCodexAppServerAuthProfile.mockClear();
mocks.resolveOpenClawAgentDir.mockClear();
});
@@ -118,6 +121,11 @@ describe("shared Codex app-server client", () => {
authProfileId: "openai-codex:work",
}),
);
expect(mocks.applyCodexAppServerAuthProfile).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId: "openai-codex:work",
}),
);
});
it("restarts the shared client when the bridged auth token changes", async () => {

View File

@@ -1,5 +1,5 @@
import { resolveOpenClawAgentDir } from "openclaw/plugin-sdk/provider-auth";
import { bridgeCodexAppServerStartOptions } from "./auth-bridge.js";
import { applyCodexAppServerAuthProfile, bridgeCodexAppServerStartOptions } from "./auth-bridge.js";
import { CodexAppServerClient } from "./client.js";
import {
codexAppServerStartOptionsKey,
@@ -35,7 +35,9 @@ export async function getSharedCodexAppServerClient(options?: {
agentDir: resolveOpenClawAgentDir(),
authProfileId: options?.authProfileId,
});
const key = codexAppServerStartOptionsKey(startOptions);
const key = codexAppServerStartOptionsKey(startOptions, {
authProfileId: options?.authProfileId,
});
if (state.key && state.key !== key) {
clearSharedCodexAppServerClient();
}
@@ -48,6 +50,11 @@ export async function getSharedCodexAppServerClient(options?: {
client.addCloseHandler(clearSharedClientIfCurrent);
try {
await client.initialize();
await applyCodexAppServerAuthProfile({
client,
agentDir: resolveOpenClawAgentDir(),
authProfileId: options?.authProfileId,
});
return client;
} catch (error) {
// Startup failures happen before callers own the shared client, so close
@@ -84,6 +91,11 @@ export async function createIsolatedCodexAppServerClient(options?: {
const initialize = client.initialize();
try {
await withTimeout(initialize, options?.timeoutMs ?? 0, "codex app-server initialize timed out");
await applyCodexAppServerAuthProfile({
client,
agentDir: resolveOpenClawAgentDir(),
authProfileId: options?.authProfileId,
});
return client;
} catch (error) {
client.close();

View File

@@ -219,16 +219,45 @@ function stabilizeJsonValue(value: JsonValue): JsonValue {
}
export function buildDeveloperInstructions(params: EmbeddedRunAttemptParams): string {
const promptOverlay = renderCodexRuntimePromptOverlay(params);
const sections = [
"You are running inside OpenClaw. Use OpenClaw dynamic tools for messaging, cron, sessions, and host actions when available.",
"Preserve the user's existing channel/session context. If sending a channel reply, use the OpenClaw messaging tool instead of describing that you would reply.",
renderCodexPromptOverlay({ modelId: params.modelId }),
promptOverlay,
params.extraSystemPrompt,
params.skillsSnapshot?.prompt,
];
return sections.filter((section) => typeof section === "string" && section.trim()).join("\n\n");
}
function renderCodexRuntimePromptOverlay(params: EmbeddedRunAttemptParams): string | undefined {
const contribution = params.runtimePlan?.prompt.resolveSystemPromptContribution({
config: params.config,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
provider: params.provider,
modelId: params.modelId,
promptMode: "full",
agentId: params.agentId,
});
if (!contribution) {
return renderCodexPromptOverlay({
config: params.config,
providerId: params.provider,
modelId: params.modelId,
});
}
return [
contribution.stablePrefix,
...Object.values(contribution.sectionOverrides ?? {}),
contribution.dynamicSuffix,
]
.filter(
(section): section is string => typeof section === "string" && section.trim().length > 0,
)
.join("\n\n");
}
function buildUserInput(
params: EmbeddedRunAttemptParams,
promptText: string = params.prompt,

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
assistantHistoryMessage,
currentPromptHistoryMessage,
mediaOnlyHistoryMessage,
structuredHistoryMessage,
} from "../../../../test/helpers/agents/transcript-repair-runtime-contract.js";
import { projectContextEngineAssemblyForCodex } from "./context-engine-projection.js";
describe("Codex transcript projection runtime contract", () => {
it("drops only the duplicate trailing current prompt while preserving prior structured context", () => {
const prompt = "newest inbound message";
const result = projectContextEngineAssemblyForCodex({
prompt,
originalHistoryMessages: [structuredHistoryMessage()],
assembledMessages: [
structuredHistoryMessage(),
assistantHistoryMessage(),
currentPromptHistoryMessage(prompt),
],
});
expect(result.promptText).toContain("Current user request:\nnewest inbound message");
expect(result.promptText).toContain("[user]\nolder structured context\n[image omitted]");
expect(result.promptText).toContain("[assistant]\nack");
expect(result.promptText).not.toContain("[user]\nnewest inbound message");
});
it("keeps media-only user history visible as omitted media instead of dropping the turn", () => {
const result = projectContextEngineAssemblyForCodex({
prompt: "newest inbound message",
originalHistoryMessages: [mediaOnlyHistoryMessage()],
assembledMessages: [
mediaOnlyHistoryMessage(),
currentPromptHistoryMessage("newest inbound message"),
],
});
expect(result.promptText).toContain("[user]\n[image omitted]");
expect(result.promptText).not.toContain("data:image/png");
expect(result.promptText).not.toContain("bbbb");
});
});

View File

@@ -0,0 +1,406 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
AUTH_PROFILE_RUNTIME_CONTRACT,
createAuthAliasManifestRegistry,
expectedForwardedAuthProfile,
} from "../../test/helpers/agents/auth-profile-runtime-contract.js";
import type { SessionEntry } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type * as ManifestRegistryModule from "../plugins/manifest-registry.js";
import { runAgentAttempt } from "./command/attempt-execution.js";
import type { EmbeddedPiRunResult } from "./pi-embedded.js";
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
type LoadPluginManifestRegistry = typeof ManifestRegistryModule.loadPluginManifestRegistry;
const loadPluginManifestRegistry = vi.hoisted(() =>
vi.fn<LoadPluginManifestRegistry>(() => ({
plugins: [],
diagnostics: [],
})),
);
const runCliAgentMock = vi.hoisted(() => vi.fn());
const runEmbeddedPiAgentMock = vi.hoisted(() => vi.fn());
vi.mock("../plugins/manifest-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/manifest-registry.js")>();
return {
...actual,
loadPluginManifestRegistry,
};
});
vi.mock("./cli-runner.js", () => ({
runCliAgent: runCliAgentMock,
}));
vi.mock("./model-selection.js", () => ({
isCliProvider: (provider: string) => {
const normalized = provider.trim().toLowerCase();
return (
normalized === AUTH_PROFILE_RUNTIME_CONTRACT.claudeCliProvider ||
normalized === AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider
);
},
normalizeProviderId: (provider: string) => provider.trim().toLowerCase(),
}));
vi.mock("./pi-embedded.js", () => ({
runEmbeddedPiAgent: runEmbeddedPiAgentMock,
}));
function makeCliResult(text: string): EmbeddedPiRunResult {
return {
payloads: [{ text }],
meta: {
durationMs: 5,
finalAssistantVisibleText: text,
agentMeta: {
sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
provider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider,
model: "gpt-5.4",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
executionTrace: {
winnerProvider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider,
winnerModel: "gpt-5.4",
fallbackUsed: false,
runner: "cli",
},
},
};
}
function makeEmbeddedResult(text: string): EmbeddedPiRunResult {
return {
payloads: [{ text }],
meta: {
durationMs: 5,
finalAssistantVisibleText: text,
agentMeta: {
sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
provider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
model: "gpt-5.4",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
executionTrace: {
winnerProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
winnerModel: "gpt-5.4",
fallbackUsed: false,
runner: "embedded",
},
},
};
}
async function runAuthContractAttempt(params: {
tmpDir: string;
storePath: string;
providerOverride: string;
authProfileProvider: string;
authProfileOverride: string;
cfg?: OpenClawConfig;
}) {
const cfg = params.cfg ?? ({} as OpenClawConfig);
const sessionEntry: SessionEntry = {
sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
updatedAt: Date.now(),
authProfileOverride: params.authProfileOverride,
authProfileOverrideSource: "user",
};
const sessionStore: Record<string, SessionEntry> = {
[AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey]: sessionEntry,
};
await fs.writeFile(params.storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
await runAgentAttempt({
providerOverride: params.providerOverride,
modelOverride: "gpt-5.4",
cfg,
sessionEntry,
sessionId: sessionEntry.sessionId,
sessionKey: AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey,
sessionAgentId: "main",
sessionFile: path.join(params.tmpDir, "session.jsonl"),
workspaceDir: params.tmpDir,
body: AUTH_PROFILE_RUNTIME_CONTRACT.workspacePrompt,
isFallbackRetry: false,
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: AUTH_PROFILE_RUNTIME_CONTRACT.runId,
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: params.tmpDir,
onAgentEvent: vi.fn(),
authProfileProvider: params.authProfileProvider,
sessionStore,
storePath: params.storePath,
sessionHasHistory: false,
});
return {
aliasLookupParams: {
config: cfg,
workspaceDir: params.tmpDir,
},
};
}
describe("Auth profile runtime contract - Pi and CLI adapter", () => {
let tmpDir: string;
let storePath: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-contract-"));
storePath = path.join(tmpDir, "sessions.json");
loadPluginManifestRegistry.mockReset().mockReturnValue(createAuthAliasManifestRegistry());
runCliAgentMock.mockReset();
runEmbeddedPiAgentMock.mockReset();
runCliAgentMock.mockResolvedValue(makeCliResult("ok"));
runEmbeddedPiAgentMock.mockResolvedValue(makeEmbeddedResult("ok"));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it.each([
[AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider],
[
AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
],
[
AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider,
AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
],
[
AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider,
AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider,
],
] as const)(
"resolves %s through the provider auth alias resolver using a mocked manifest",
(provider, expectedAuthProvider) => {
expect(
resolveProviderIdForAuth(provider, {
config: {} as OpenClawConfig,
workspaceDir: tmpDir,
}),
).toBe(expectedAuthProvider);
},
);
it("forwards an OpenAI Codex auth profile when the selected provider is codex-cli", async () => {
const { aliasLookupParams } = await runAuthContractAttempt({
tmpDir,
storePath,
providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
});
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
expect(runCliAgentMock.mock.calls[0]?.[0]?.authProfileId).toBe(
expectedForwardedAuthProfile({
provider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
aliasLookupParams,
sessionAuthProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
}),
);
});
it("forwards an OpenAI Codex auth profile when the auth provider is the legacy codex-cli alias", async () => {
const { aliasLookupParams } = await runAuthContractAttempt({
tmpDir,
storePath,
providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider,
authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
});
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
expect(runCliAgentMock.mock.calls[0]?.[0]?.authProfileId).toBe(
expectedForwardedAuthProfile({
provider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider,
aliasLookupParams,
sessionAuthProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
}),
);
});
it("does not leak an OpenAI API-key auth profile into the Codex CLI alias", async () => {
await runAuthContractAttempt({
tmpDir,
storePath,
providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider,
authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProfileId,
});
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
expect(runCliAgentMock.mock.calls[0]?.[0]?.authProfileId).toBeUndefined();
});
it("does not leak an OpenAI Codex auth profile into an unrelated CLI provider", async () => {
await runAuthContractAttempt({
tmpDir,
storePath,
providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.claudeCliProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
});
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
expect(runCliAgentMock.mock.calls[0]?.[0]?.authProfileId).toBeUndefined();
});
it("does not let a configured Codex harness leak OpenAI Codex auth into unrelated CLI providers", async () => {
await runAuthContractAttempt({
tmpDir,
storePath,
providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.claudeCliProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
cfg: {
agents: {
defaults: {
embeddedHarness: { runtime: "codex", fallback: "none" },
},
},
} as OpenClawConfig,
});
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
expect(runCliAgentMock.mock.calls[0]?.[0]?.authProfileId).toBeUndefined();
});
it("forwards an OpenAI Codex auth profile through the embedded Pi path", async () => {
await runAuthContractAttempt({
tmpDir,
storePath,
providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
});
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.authProfileId).toBe(
AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
);
});
it("accepts the legacy codex-cli auth-provider alias on the embedded OpenAI Codex path", async () => {
const { aliasLookupParams } = await runAuthContractAttempt({
tmpDir,
storePath,
providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider,
authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
});
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.authProfileId).toBe(
expectedForwardedAuthProfile({
provider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider,
aliasLookupParams,
sessionAuthProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
}),
);
});
it("forwards an OpenAI auth profile through the embedded OpenAI path", async () => {
await runAuthContractAttempt({
tmpDir,
storePath,
providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider,
authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProfileId,
});
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.authProfileId).toBe(
AUTH_PROFILE_RUNTIME_CONTRACT.openAiProfileId,
);
});
it("does not leak an OpenAI Codex auth profile into an unrelated embedded provider", async () => {
await runAuthContractAttempt({
tmpDir,
storePath,
providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
});
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.authProfileId).toBeUndefined();
});
it("preserves OpenAI Codex auth profiles through the real codex/* harness startup path", async () => {
await runAuthContractAttempt({
tmpDir,
storePath,
providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
cfg: {
agents: {
defaults: {
embeddedHarness: { runtime: "codex", fallback: "none" },
},
},
} as OpenClawConfig,
});
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({
agentHarnessId: "codex",
authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
});
});
it("validates openai/* forced through the Codex harness can use OpenAI Codex OAuth profiles", async () => {
await runAuthContractAttempt({
tmpDir,
storePath,
providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider,
authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
cfg: {
agents: {
defaults: {
embeddedHarness: { runtime: "codex", fallback: "none" },
},
},
} as OpenClawConfig,
});
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({
agentHarnessId: "codex",
authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
});
});
});

View File

@@ -5,6 +5,7 @@ import {
patchCodexNativeWebSearchPayload,
resolveCodexNativeSearchActivation,
resolveCodexNativeWebSearchConfig,
isCodexNativeWebSearchRelevant,
shouldSuppressManagedWebSearchTool,
} from "./codex-native-web-search.js";
@@ -230,9 +231,7 @@ describe("shouldSuppressManagedWebSearchTool", () => {
});
describe("isCodexNativeWebSearchRelevant", () => {
it("treats a default model with model-level openai-codex-responses api as relevant", async () => {
const { isCodexNativeWebSearchRelevant } = await import("./codex-native-web-search.js");
it("treats a default model with model-level openai-codex-responses api as relevant", () => {
expect(
isCodexNativeWebSearchRelevant({
config: {

View File

@@ -18,7 +18,7 @@ import { resolveAgentHarnessPolicy } from "../harness/selection.js";
import { isCliProvider } from "../model-selection.js";
import { prepareSessionManagerForRun } from "../pi-embedded-runner/session-manager-init.js";
import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../pi-embedded.js";
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js";
import { buildWorkspaceSkillSnapshot } from "../skills.js";
import { buildUsageWithNoCost } from "../stream-message-shared.js";
import {
@@ -272,18 +272,24 @@ export function runAgentAttempt(params: {
sessionId: params.sessionId,
sessionKey: params.sessionKey ?? params.sessionId,
});
const providerAuthKey = resolveProviderIdForAuth(params.providerOverride, {
const agentHarnessPolicy = resolveAgentHarnessPolicy({
provider: params.providerOverride,
modelId: params.modelOverride,
config: params.cfg,
agentId: params.sessionAgentId,
sessionKey: params.sessionKey ?? params.sessionId,
});
const runtimeAuthPlan = buildAgentRuntimeAuthPlan({
provider: params.providerOverride,
authProfileProvider: params.authProfileProvider,
sessionAuthProfileId: params.sessionEntry?.authProfileOverride,
config: params.cfg,
workspaceDir: params.workspaceDir,
harnessId: sessionPinnedAgentHarnessId,
harnessRuntime: agentHarnessPolicy.runtime,
allowHarnessAuthProfileForwarding: !isCliProvider(params.providerOverride, params.cfg),
});
const authProfileProviderKey = resolveProviderIdForAuth(params.authProfileProvider, {
config: params.cfg,
workspaceDir: params.workspaceDir,
});
const authProfileId =
providerAuthKey === authProfileProviderKey
? params.sessionEntry?.authProfileOverride
: undefined;
const authProfileId = runtimeAuthPlan.forwardedAuthProfileId;
if (isCliProvider(params.providerOverride, params.cfg)) {
const cliSessionBinding = getCliSessionBinding(params.sessionEntry, params.providerOverride);
const resolveReusableCliSessionBinding = async () => {

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import {
isStrictOpenAIJsonSchemaCompatible,
normalizeStrictOpenAIJsonSchema,
resolveOpenAIStrictToolFlagForInventory,
} from "./openai-tool-schema.js";
describe("OpenAI strict tool schema normalization", () => {
it("does not close permissive nested object schemas implicitly", () => {
const schema = {
type: "object",
properties: {
metadata: {
type: "object",
},
},
required: ["metadata"],
};
const normalized = normalizeStrictOpenAIJsonSchema(schema) as {
additionalProperties?: boolean;
properties?: { metadata?: { additionalProperties?: boolean } };
};
expect(normalized.additionalProperties).toBe(false);
expect(normalized.properties?.metadata).not.toHaveProperty("additionalProperties");
expect(isStrictOpenAIJsonSchemaCompatible(schema)).toBe(false);
expect(
resolveOpenAIStrictToolFlagForInventory([{ name: "write", parameters: schema }], true),
).toBe(false);
});
});

View File

@@ -10,14 +10,14 @@ type ToolWithParameters = {
};
export function normalizeStrictOpenAIJsonSchema(schema: unknown): unknown {
return normalizeStrictOpenAIJsonSchemaRecursive(normalizeToolParameterSchema(schema ?? {}));
return normalizeStrictOpenAIJsonSchemaRecursive(normalizeToolParameterSchema(schema ?? {}), 0);
}
function normalizeStrictOpenAIJsonSchemaRecursive(schema: unknown): unknown {
function normalizeStrictOpenAIJsonSchemaRecursive(schema: unknown, depth: number): unknown {
if (Array.isArray(schema)) {
let changed = false;
const normalized = schema.map((entry) => {
const next = normalizeStrictOpenAIJsonSchemaRecursive(entry);
const next = normalizeStrictOpenAIJsonSchemaRecursive(entry, depth);
changed ||= next !== entry;
return next;
});
@@ -31,7 +31,10 @@ function normalizeStrictOpenAIJsonSchemaRecursive(schema: unknown): unknown {
let changed = false;
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(record)) {
const next = normalizeStrictOpenAIJsonSchemaRecursive(value);
const next = normalizeStrictOpenAIJsonSchemaRecursive(
value,
key === "properties" ? depth : depth + 1,
);
normalized[key] = next;
changed ||= next !== value;
}
@@ -47,6 +50,10 @@ function normalizeStrictOpenAIJsonSchemaRecursive(schema: unknown): unknown {
normalized.required = [];
changed = true;
}
if (depth === 0 && !("additionalProperties" in normalized)) {
normalized.additionalProperties = false;
changed = true;
}
}
return changed ? normalized : schema;

View File

@@ -1718,7 +1718,12 @@ describe("openai transport stream", () => {
{
name: "read",
description: "Read file",
parameters: { type: "object", properties: {} },
parameters: {
type: "object",
additionalProperties: false,
properties: { path: { type: "string" } },
required: [],
},
},
],
} as never,

View File

@@ -0,0 +1,390 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
installOpenClawOwnedToolHooks,
resetOpenClawOwnedToolHooks,
textToolResult,
} from "../../test/helpers/agents/openclaw-owned-tool-runtime-contract.js";
import type { MessagingToolSend } from "./pi-embedded-messaging.types.js";
import {
handleToolExecutionEnd,
handleToolExecutionStart,
} from "./pi-embedded-subscribe.handlers.tools.js";
import type {
ToolCallSummary,
ToolHandlerContext,
} from "./pi-embedded-subscribe.handlers.types.js";
import { toToolDefinitions } from "./pi-tool-definition-adapter.js";
import { createBaseToolHandlerState } from "./pi-tool-handler-state.test-helpers.js";
import { wrapToolWithBeforeToolCallHook } from "./pi-tools.before-tool-call.js";
function createContractTool(name: string, execute: AgentTool["execute"]): AgentTool {
return {
name,
label: name,
description: `contract tool: ${name}`,
parameters: { type: "object", properties: {} },
execute,
} as AgentTool;
}
type ToolExecutionStartEvent = Parameters<typeof handleToolExecutionStart>[1];
type ToolExecutionEndEvent = Parameters<typeof handleToolExecutionEnd>[1];
function createToolHandlerCtx(): ToolHandlerContext {
return {
params: {
runId: "run-contract",
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
},
state: {
...createBaseToolHandlerState(),
toolMetaById: new Map<string, ToolCallSummary>(),
pendingMessagingTargets: new Map<string, MessagingToolSend>(),
messagingToolSentTargets: [] as MessagingToolSend[],
successfulCronAdds: 0,
},
log: { debug: vi.fn(), warn: vi.fn() },
flushBlockReplyBuffer: vi.fn(),
shouldEmitToolResult: () => false,
shouldEmitToolOutput: () => false,
emitToolSummary: vi.fn(),
emitToolOutput: vi.fn(),
trimMessagingToolSent: vi.fn(),
};
}
function toolExecutionStartEvent(params: {
toolName: string;
toolCallId: string;
args: unknown;
}): ToolExecutionStartEvent {
return {
type: "tool_execution_start",
toolName: params.toolName,
toolCallId: params.toolCallId,
args: params.args,
} as ToolExecutionStartEvent;
}
function toolExecutionEndEvent(params: {
toolName: string;
toolCallId: string;
isError: boolean;
result: unknown;
}): ToolExecutionEndEvent {
return {
type: "tool_execution_end",
toolName: params.toolName,
toolCallId: params.toolCallId,
isError: params.isError,
result: params.result,
} as ToolExecutionEndEvent;
}
function createToolExtensionContext(): ExtensionContext {
return {} as ExtensionContext;
}
describe("OpenClaw-owned tool runtime contract — Pi adapter", () => {
afterEach(() => {
resetOpenClawOwnedToolHooks();
});
it("preserves partially adjusted before_tool_call params through execution and after_tool_call", async () => {
const adjustedParams = { mode: "safe" };
const mergedParams = { command: "pwd", mode: "safe" };
const hooks = installOpenClawOwnedToolHooks({ adjustedParams });
const execute = vi.fn(async () => textToolResult("done", { ok: true }));
const tool = wrapToolWithBeforeToolCallHook(createContractTool("exec", execute), {
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-contract",
});
const definition = toToolDefinitions([tool])[0];
if (!definition) {
throw new Error("missing Pi tool definition");
}
const ctx = createToolHandlerCtx();
const toolCallId = "call-contract";
const originalParams = { command: "pwd" };
await handleToolExecutionStart(
ctx,
toolExecutionStartEvent({
toolName: "exec",
toolCallId,
args: originalParams,
}),
);
const result = await definition.execute(
toolCallId,
originalParams,
undefined,
undefined,
createToolExtensionContext(),
);
await handleToolExecutionEnd(
ctx,
toolExecutionEndEvent({
toolName: "exec",
toolCallId,
isError: false,
result,
}),
);
expect(hooks.beforeToolCall).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith(toolCallId, mergedParams, undefined, undefined);
await vi.waitFor(() => {
expect(hooks.afterToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "exec",
toolCallId,
params: mergedParams,
result: expect.objectContaining({
content: [{ type: "text", text: "done" }],
details: { ok: true },
}),
}),
expect.objectContaining({
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-contract",
toolCallId,
}),
);
});
});
it("reports Pi dynamic tool execution errors through after_tool_call", async () => {
const adjustedParams = { timeoutSec: 1 };
const mergedParams = { command: "false", timeoutSec: 1 };
const hooks = installOpenClawOwnedToolHooks({ adjustedParams });
const execute = vi.fn(async () => {
throw new Error("tool failed");
});
const tool = wrapToolWithBeforeToolCallHook(createContractTool("exec", execute), {
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-error",
});
const definition = toToolDefinitions([tool])[0];
if (!definition) {
throw new Error("missing Pi tool definition");
}
const ctx = createToolHandlerCtx();
ctx.params.runId = "run-error";
const toolCallId = "call-error";
const originalParams = { command: "false" };
await handleToolExecutionStart(
ctx,
toolExecutionStartEvent({
toolName: "exec",
toolCallId,
args: originalParams,
}),
);
const result = await definition.execute(
toolCallId,
originalParams,
undefined,
undefined,
createToolExtensionContext(),
);
expect(result).toEqual(
expect.objectContaining({
details: expect.objectContaining({
status: "error",
error: "tool failed",
}),
}),
);
await handleToolExecutionEnd(
ctx,
toolExecutionEndEvent({
toolName: "exec",
toolCallId,
isError: true,
result,
}),
);
expect(hooks.beforeToolCall).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith(toolCallId, mergedParams, undefined, undefined);
await vi.waitFor(() => {
expect(hooks.afterToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "exec",
toolCallId,
params: mergedParams,
error: "tool failed",
}),
expect.objectContaining({
runId: "run-error",
toolCallId,
}),
);
});
});
it("commits successful Pi messaging text, media, and target telemetry", async () => {
const hooks = installOpenClawOwnedToolHooks();
const execute = vi.fn(async () => textToolResult("sent"));
const tool = wrapToolWithBeforeToolCallHook(createContractTool("message", execute), {
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-message",
});
const definition = toToolDefinitions([tool])[0];
if (!definition) {
throw new Error("missing Pi tool definition");
}
const ctx = createToolHandlerCtx();
ctx.params.runId = "run-message";
const toolCallId = "call-message";
const originalParams = {
action: "send",
content: "hello from Pi",
mediaUrl: "/tmp/pi-reply.png",
provider: "telegram",
to: "chat-1",
};
await handleToolExecutionStart(
ctx,
toolExecutionStartEvent({
toolName: "message",
toolCallId,
args: originalParams,
}),
);
const result = await definition.execute(
toolCallId,
originalParams,
undefined,
undefined,
createToolExtensionContext(),
);
await handleToolExecutionEnd(
ctx,
toolExecutionEndEvent({
toolName: "message",
toolCallId,
isError: false,
result,
}),
);
expect(ctx.state.messagingToolSentTexts).toEqual(["hello from Pi"]);
expect(ctx.state.messagingToolSentMediaUrls).toEqual(["/tmp/pi-reply.png"]);
expect(ctx.state.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "telegram",
to: "chat-1",
}),
]);
await vi.waitFor(() => {
expect(hooks.afterToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "message",
toolCallId,
params: originalParams,
result: expect.objectContaining({
content: [{ type: "text", text: "sent" }],
}),
}),
expect.objectContaining({
runId: "run-message",
toolCallId,
}),
);
});
});
it("fails closed when before_tool_call blocks a Pi dynamic tool", async () => {
const hooks = installOpenClawOwnedToolHooks({ blockReason: "blocked by policy" });
const execute = vi.fn(async () => textToolResult("should not run"));
const tool = wrapToolWithBeforeToolCallHook(createContractTool("message", execute), {
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-blocked",
});
const definition = toToolDefinitions([tool])[0];
if (!definition) {
throw new Error("missing Pi tool definition");
}
const ctx = createToolHandlerCtx();
ctx.params.runId = "run-blocked";
const toolCallId = "call-blocked";
const originalParams = {
action: "send",
text: "blocked",
provider: "telegram",
to: "chat-1",
};
await handleToolExecutionStart(
ctx,
toolExecutionStartEvent({
toolName: "message",
toolCallId,
args: originalParams,
}),
);
const result = await definition.execute(
toolCallId,
originalParams,
undefined,
undefined,
createToolExtensionContext(),
);
expect(result).toEqual(
expect.objectContaining({
details: expect.objectContaining({
status: "error",
error: "blocked by policy",
}),
}),
);
await handleToolExecutionEnd(
ctx,
toolExecutionEndEvent({
toolName: "message",
toolCallId,
isError: true,
result,
}),
);
expect(hooks.beforeToolCall).toHaveBeenCalledTimes(1);
expect(execute).not.toHaveBeenCalled();
await vi.waitFor(() => {
expect(hooks.afterToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "message",
toolCallId,
params: originalParams,
error: "blocked by policy",
}),
expect.objectContaining({
agentId: "agent-1",
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-blocked",
toolCallId,
}),
);
});
});
});

View File

@@ -0,0 +1,187 @@
import { describe, expect, it, vi } from "vitest";
import {
createContractFallbackConfig,
createContractRunResult,
OUTCOME_FALLBACK_RUNTIME_CONTRACT,
} from "../../test/helpers/agents/outcome-fallback-runtime-contract.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { runWithModelFallback } from "./model-fallback.js";
import { classifyEmbeddedPiRunResultForModelFallback } from "./pi-embedded-runner/result-fallback-classifier.js";
vi.mock("./auth-profiles/source-check.js", () => ({
hasAnyAuthProfileStoreSource: () => false,
}));
describe("Outcome/fallback runtime contract - Pi fallback classifier", () => {
it.each([
["empty", "empty_result"],
["reasoning-only", "reasoning_only_result"],
["planning-only", "planning_only_result"],
] as const)(
"maps harness classification %s to a format fallback code",
(classification, code) => {
expect(
classifyEmbeddedPiRunResultForModelFallback({
provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider,
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
result: createContractRunResult({
meta: {
durationMs: 1,
agentHarnessResultClassification: classification,
},
}),
}),
).toMatchObject({
reason: "format",
code,
});
},
);
it.each([
["empty", "empty_result"],
["reasoning-only", "reasoning_only_result"],
["planning-only", "planning_only_result"],
] as const)(
"advances to the configured fallback after a classified GPT-5 %s terminal result",
async (classification, code) => {
const primary = createContractRunResult({
meta: {
durationMs: 1,
agentHarnessResultClassification: classification,
},
});
const fallback = createContractRunResult({
payloads: [{ text: "fallback ok" }],
meta: { durationMs: 1, finalAssistantVisibleText: "fallback ok" },
});
const run = vi.fn().mockResolvedValueOnce(primary).mockResolvedValueOnce(fallback);
const result = await runWithModelFallback({
cfg: createContractFallbackConfig() as unknown as OpenClawConfig,
provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider,
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
run,
classifyResult: ({ provider, model, result }) =>
classifyEmbeddedPiRunResultForModelFallback({
provider,
model,
result,
}),
});
expect(result.result).toBe(fallback);
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]).toEqual([
OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackProvider,
OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackModel,
]);
expect(result.attempts[0]).toMatchObject({
provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider,
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
reason: "format",
code,
});
},
);
it.each([
{
name: "intentional NO_REPLY",
result: createContractRunResult({
meta: { durationMs: 1, finalAssistantRawText: "NO_REPLY" },
}),
},
{
name: "visible reply",
result: createContractRunResult({
payloads: [{ text: "visible answer" }],
meta: { durationMs: 1 },
}),
},
{
name: "abort",
result: createContractRunResult({
meta: { durationMs: 1, aborted: true, agentHarnessResultClassification: "empty" },
}),
},
{
name: "tool summary side effect",
result: createContractRunResult({
meta: { durationMs: 1, toolSummary: { calls: 1, tools: ["message"] } },
}),
},
{
name: "messaging text side effect",
result: createContractRunResult({
messagingToolSentTexts: ["sent out of band"],
meta: { durationMs: 1, agentHarnessResultClassification: "empty" },
}),
},
{
name: "messaging media side effect",
result: createContractRunResult({
messagingToolSentMediaUrls: ["https://example.test/image.png"],
meta: { durationMs: 1, agentHarnessResultClassification: "empty" },
}),
},
{
name: "messaging target side effect",
result: createContractRunResult({
messagingToolSentTargets: [{ tool: "message", provider: "slack", to: "channel-1" }],
meta: { durationMs: 1, agentHarnessResultClassification: "empty" },
}),
},
{
name: "cron side effect",
result: createContractRunResult({
successfulCronAdds: 1,
meta: { durationMs: 1, agentHarnessResultClassification: "empty" },
}),
},
{
name: "direct block reply",
result: createContractRunResult({
meta: { durationMs: 1, agentHarnessResultClassification: "empty" },
}),
hasDirectlySentBlockReply: true,
},
{
name: "block reply pipeline output",
result: createContractRunResult({
meta: { durationMs: 1, agentHarnessResultClassification: "empty" },
}),
hasBlockReplyPipelineOutput: true,
},
])("does not fallback for $name", async (contractCase) => {
expect(
classifyEmbeddedPiRunResultForModelFallback({
provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider,
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
result: contractCase.result,
hasDirectlySentBlockReply: contractCase.hasDirectlySentBlockReply,
hasBlockReplyPipelineOutput: contractCase.hasBlockReplyPipelineOutput,
}),
).toBeNull();
const run = vi.fn().mockResolvedValue(contractCase.result);
const result = await runWithModelFallback({
cfg: createContractFallbackConfig() as unknown as OpenClawConfig,
provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider,
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
run,
classifyResult: ({ provider, model, result }) =>
classifyEmbeddedPiRunResultForModelFallback({
provider,
model,
result,
hasDirectlySentBlockReply: contractCase.hasDirectlySentBlockReply,
hasBlockReplyPipelineOutput: contractCase.hasBlockReplyPipelineOutput,
}),
});
expect(result.result).toBe(contractCase.result);
expect(result.attempts).toEqual([]);
expect(run).toHaveBeenCalledTimes(1);
});
});

View File

@@ -390,6 +390,7 @@ export async function loadCompactHooksHarness(): Promise<{
vi.doMock("./extra-params.js", () => ({
applyExtraParamsToAgent: applyExtraParamsToAgentMock,
resolveAgentTransportOverride: resolveAgentTransportOverrideMock,
resolvePreparedExtraParams: vi.fn(() => ({})),
}));
vi.doMock("./tool-split.js", () => ({

View File

@@ -282,6 +282,8 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
api: "responses",
}),
"/tmp/workspace",
undefined,
undefined,
);
});

View File

@@ -26,7 +26,6 @@ import { extractModelCompat } from "../../plugins/provider-model-compat.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import {
prepareProviderRuntimeAuth,
resolveProviderSystemPromptContribution,
resolveProviderTextTransforms,
transformProviderSystemPrompt,
} from "../../plugins/provider-runtime.js";
@@ -76,6 +75,8 @@ import { applyPiCompactionSettingsFromConfig } from "../pi-settings.js";
import { createOpenClawCodingTools } from "../pi-tools.js";
import { wrapStreamFnTextTransforms } from "../plugin-text-transforms.js";
import { registerProviderStreamForModel } from "../provider-stream.js";
import { buildAgentRuntimePlan } from "../runtime-plan/build.js";
import type { AgentRuntimePlan } from "../runtime-plan/types.js";
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
import { resolveSandboxContext } from "../sandbox.js";
import { repairSessionFileIfNeeded } from "../session-file-repair.js";
@@ -92,7 +93,6 @@ import {
resolveSkillsPromptForRun,
} from "../skills.js";
import { resolveSystemPromptOverride } from "../system-prompt-override.js";
import { resolveTranscriptPolicy } from "../transcript-policy.js";
import { classifyCompactionReason, resolveCompactionFailureReason } from "./compact-reasons.js";
import type { CompactEmbeddedPiSessionParams, CompactionMessageMetrics } from "./compact.types.js";
import {
@@ -138,10 +138,6 @@ import {
collectRegisteredToolNames,
toSessionToolAllowlist,
} from "./tool-name-allowlist.js";
import {
logProviderToolSchemaDiagnostics,
normalizeProviderToolSchemas,
} from "./tool-schema-runtime.js";
import { splitSdkTools } from "./tool-split.js";
import type { EmbeddedPiCompactResult } from "./types.js";
import { mapThinkingLevel } from "./utils.js";
@@ -177,6 +173,7 @@ function prepareCompactionSessionAgent(params: {
sessionAgentId: string;
effectiveWorkspace: string;
agentDir: string;
runtimePlan?: AgentRuntimePlan;
}) {
params.session.agent.streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: resolveEmbeddedAgentBaseStreamFn({ session: params.session as never }),
@@ -202,6 +199,12 @@ function prepareCompactionSessionAgent(params: {
transformSystemPrompt: false,
}) as never;
}
const preparedRuntimeExtraParams = params.runtimePlan?.transport.resolveExtraParams({
thinkingLevel: params.thinkLevel,
agentId: params.sessionAgentId,
workspaceDir: params.effectiveWorkspace,
model: params.effectiveModel,
});
return applyExtraParamsToAgent(
params.session.agent as never,
params.config,
@@ -213,6 +216,8 @@ function prepareCompactionSessionAgent(params: {
params.effectiveWorkspace,
params.effectiveModel,
params.agentDir,
undefined,
preparedRuntimeExtraParams ? { preparedExtraParams: preparedRuntimeExtraParams } : undefined,
);
}
@@ -501,6 +506,23 @@ export async function compactEmbeddedPiSessionDirect(
hasRuntimeAuthExchange ? null : apiKeyInfo,
params.config,
);
const runtimePlan =
params.runtimePlan ??
buildAgentRuntimePlan({
provider,
modelId,
model: effectiveModel,
modelApi: effectiveModel.api,
harnessId: params.agentHarnessId,
harnessRuntime: params.agentHarnessId,
authProfileProvider: authProfileId?.split(":", 1)[0],
sessionAuthProfileId: authProfileId,
config: params.config,
workspaceDir: effectiveWorkspace,
agentDir,
agentId: effectiveSkillAgentId,
thinkingLevel: thinkLevel,
});
const runAbortController = new AbortController();
const toolsRaw = createOpenClawCodingTools({
@@ -535,16 +557,15 @@ export async function compactEmbeddedPiSessionDirect(
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
});
const toolsEnabled = supportsModelTools(runtimeModel);
const tools = normalizeProviderToolSchemas({
tools: toolsEnabled ? toolsRaw : [],
provider,
config: params.config,
const runtimePlanModelContext = {
workspaceDir: effectiveWorkspace,
env: process.env,
modelId,
modelApi: model.api,
model,
});
};
const tools = runtimePlan.tools.normalize(
toolsEnabled ? toolsRaw : [],
runtimePlanModelContext,
);
const bundleMcpRuntime = toolsEnabled
? await createBundleMcpToolRuntime({
workspaceDir: effectiveWorkspace,
@@ -590,16 +611,7 @@ export async function compactEmbeddedPiSessionDirect(
});
const effectiveTools = [...tools, ...filteredBundledTools];
const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools });
logProviderToolSchemaDiagnostics({
tools: effectiveTools,
provider,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
modelId,
modelApi: model.api,
model,
});
runtimePlan.tools.logDiagnostics(effectiveTools, runtimePlanModelContext);
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
let runtimeCapabilities = runtimeChannel
@@ -704,22 +716,21 @@ export async function compactEmbeddedPiSessionDirect(
});
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
const ownerDisplay = resolveOwnerDisplaySetting(params.config);
const promptContribution = resolveProviderSystemPromptContribution({
provider,
const promptContributionContext: Parameters<
AgentRuntimePlan["prompt"]["resolveSystemPromptContribution"]
>[0] = {
config: params.config,
agentDir,
workspaceDir: effectiveWorkspace,
context: {
config: params.config,
agentDir,
workspaceDir: effectiveWorkspace,
provider,
modelId,
promptMode,
runtimeChannel,
runtimeCapabilities,
agentId: sessionAgentId,
},
});
provider,
modelId,
promptMode,
runtimeChannel,
runtimeCapabilities,
agentId: sessionAgentId,
};
const promptContribution =
runtimePlan.prompt.resolveSystemPromptContribution(promptContributionContext);
const buildSystemPromptOverride = (defaultThinkLevel: ThinkLevel) => {
const builtSystemPrompt =
resolveSystemPromptOverride({
@@ -792,15 +803,7 @@ export async function compactEmbeddedPiSessionDirect(
warn: (message) => log.warn(message),
});
await prewarmSessionFile(params.sessionFile);
const transcriptPolicy = resolveTranscriptPolicy({
modelApi: model.api,
provider,
modelId,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
model,
});
const transcriptPolicy = runtimePlan.transcript.resolvePolicy(runtimePlanModelContext);
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
agentId: sessionAgentId,
sessionKey: params.sessionKey,
@@ -917,6 +920,7 @@ export async function compactEmbeddedPiSessionDirect(
sessionAgentId,
effectiveWorkspace,
agentDir,
runtimePlan,
});
const prior = await sanitizeSessionHistory({

View File

@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ContextEngine, ContextEngineRuntimeContext } from "../../context-engine/types.js";
import type { CommandQueueEnqueueFn } from "../../process/command-queue.types.js";
import type { ExecElevatedDefaults } from "../bash-tools.exec-types.js";
import type { AgentRuntimePlan } from "../runtime-plan/types.js";
import type { SkillSnapshot } from "../skills.js";
export type CompactEmbeddedPiSessionParams = {
@@ -50,6 +51,8 @@ export type CompactEmbeddedPiSessionParams = {
contextEngineRuntimeContext?: ContextEngineRuntimeContext;
/** Session-pinned embedded harness id. Prevents compaction hot-switching. */
agentHarnessId?: string;
/** OpenClaw-owned runtime policy prepared for this compaction path. */
runtimePlan?: AgentRuntimePlan;
thinkLevel?: ThinkLevel;
reasoningLevel?: ReasoningLevel;
bashElevated?: ExecElevatedDefaults;

View File

@@ -494,6 +494,7 @@ export function applyExtraParamsToAgent(
model?: ProviderRuntimeModel,
agentDir?: string,
resolvedTransport?: SupportedTransport,
options?: { preparedExtraParams?: Record<string, unknown> },
): { effectiveExtraParams: Record<string, unknown> } {
const resolvedExtraParams = resolveExtraParams({
cfg,
@@ -507,19 +508,21 @@ export function applyExtraParamsToAgent(
Object.entries(extraParamsOverride).filter(([, value]) => value !== undefined),
)
: undefined;
const effectiveExtraParams = resolvePreparedExtraParams({
cfg,
provider,
modelId,
extraParamsOverride,
thinkingLevel,
agentId,
agentDir,
workspaceDir,
resolvedExtraParams,
model,
resolvedTransport,
});
const effectiveExtraParams =
options?.preparedExtraParams ??
resolvePreparedExtraParams({
cfg,
provider,
modelId,
extraParamsOverride,
thinkingLevel,
agentId,
agentDir,
workspaceDir,
resolvedExtraParams,
model,
resolvedTransport,
});
const wrapperContext: ApplyExtraParamsContext = {
agent,
cfg,

View File

@@ -97,4 +97,89 @@ describe("runEmbeddedPiAgent forwards optional params to runEmbeddedAttempt", ()
expect(mockedGetApiKeyForModel).not.toHaveBeenCalled();
expect(pluginRunAttempt).toHaveBeenCalledWith(expect.objectContaining({ provider: "codex" }));
});
it("forwards explicit OpenAI Codex auth profiles to codex plugin harnesses", async () => {
const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js");
const pluginRunAttempt = vi.fn(async () => makeAttemptResult({ assistantTexts: ["ok"] }));
clearAgentHarnesses();
registerAgentHarness({
id: "codex",
label: "Codex",
supports: (ctx) =>
ctx.provider === "codex" ? { supported: true, priority: 100 } : { supported: false },
runAttempt: pluginRunAttempt,
});
mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped"));
try {
await runEmbeddedPiAgent({
...overflowBaseRunParams,
provider: "codex",
model: "gpt-5.4",
config: {
agents: {
defaults: {
embeddedHarness: { runtime: "codex", fallback: "none" },
},
},
},
authProfileId: "openai-codex:work",
authProfileIdSource: "user",
runId: "plugin-harness-forwards-openai-codex-auth",
});
} finally {
clearAgentHarnesses();
}
expect(mockedGetApiKeyForModel).not.toHaveBeenCalled();
expect(pluginRunAttempt).toHaveBeenCalledWith(
expect.objectContaining({
provider: "codex",
authProfileId: "openai-codex:work",
authProfileIdSource: "user",
}),
);
});
it("forwards OpenAI Codex auth profiles when openai/* is forced through codex", async () => {
const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js");
const pluginRunAttempt = vi.fn(async () => makeAttemptResult({ assistantTexts: ["ok"] }));
clearAgentHarnesses();
registerAgentHarness({
id: "codex",
label: "Codex",
supports: () => ({ supported: false }),
runAttempt: pluginRunAttempt,
});
mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped"));
try {
await runEmbeddedPiAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-5.4",
config: {
agents: {
defaults: {
embeddedHarness: { runtime: "codex", fallback: "none" },
},
},
},
authProfileId: "openai-codex:work",
authProfileIdSource: "user",
runId: "forced-codex-harness-forwards-openai-codex-auth",
});
} finally {
clearAgentHarnesses();
}
expect(mockedGetApiKeyForModel).not.toHaveBeenCalled();
expect(pluginRunAttempt).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
authProfileId: "openai-codex:work",
authProfileIdSource: "user",
}),
);
});
});

View File

@@ -62,9 +62,12 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
const result = await runEmbeddedPiAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-4.1",
runId: "run-incomplete-turn-messaging-warning",
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
expect(mockedClassifyFailoverReason).toHaveBeenCalledTimes(1);
expect(result.payloads?.[0]?.isError).toBe(true);
expect(result.payloads?.[0]?.text).toContain("verify before retrying");

View File

@@ -76,6 +76,8 @@ import {
pickFallbackThinkingLevel,
} from "../pi-embedded-helpers.js";
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js";
import { buildAgentRuntimePlan } from "../runtime-plan/build.js";
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js";
import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js";
@@ -415,22 +417,36 @@ export async function runEmbeddedPiAgent(
const preferredProfileId = params.authProfileId?.trim();
let lockedProfileId = params.authProfileIdSource === "user" ? preferredProfileId : undefined;
if (lockedProfileId) {
const lockedProfile = authStore.profiles[lockedProfileId];
const lockedProfileProvider = lockedProfile
? resolveProviderIdForAuth(lockedProfile.provider, {
config: params.config,
workspaceDir: resolvedWorkspace,
})
: undefined;
const runProvider = resolveProviderIdForAuth(provider, {
config: params.config,
workspaceDir: resolvedWorkspace,
});
if (!lockedProfile || !lockedProfileProvider || lockedProfileProvider !== runProvider) {
lockedProfileId = undefined;
if (pluginHarnessOwnsTransport) {
const runtimeAuthPlan = buildAgentRuntimeAuthPlan({
provider,
authProfileProvider: lockedProfileId.split(":", 1)[0],
sessionAuthProfileId: lockedProfileId,
config: params.config,
workspaceDir: resolvedWorkspace,
harnessId: agentHarness.id,
});
if (!runtimeAuthPlan.forwardedAuthProfileId) {
lockedProfileId = undefined;
}
} else {
const lockedProfile = authStore.profiles[lockedProfileId];
const lockedProfileProvider = lockedProfile
? resolveProviderIdForAuth(lockedProfile.provider, {
config: params.config,
workspaceDir: resolvedWorkspace,
})
: undefined;
const runProvider = resolveProviderIdForAuth(provider, {
config: params.config,
workspaceDir: resolvedWorkspace,
});
if (!lockedProfile || !lockedProfileProvider || lockedProfileProvider !== runProvider) {
lockedProfileId = undefined;
}
}
}
if (lockedProfileId) {
if (lockedProfileId && !pluginHarnessOwnsTransport) {
const eligibility = resolveAuthProfileEligibility({
cfg: params.config,
store: authStore,
@@ -547,6 +563,8 @@ export async function runEmbeddedPiAgent(
// vendor-token refresh attempts before the plugin gets control.
if (!pluginHarnessOwnsTransport) {
await initializeAuthProfile();
} else if (lockedProfileId) {
lastProfileId = lockedProfileId;
}
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
@@ -791,6 +809,26 @@ export async function runEmbeddedPiAgent(
if (!runtimeAuthState && apiKeyInfo) {
resolvedStreamApiKey = (apiKeyInfo as ApiKeyInfo).apiKey;
}
const runtimePlan = buildAgentRuntimePlan({
provider,
modelId,
model: effectiveModel,
modelApi: effectiveModel.api,
harnessId: agentHarness.id,
harnessRuntime: agentHarness.id,
allowHarnessAuthProfileForwarding: pluginHarnessOwnsTransport,
authProfileProvider: lastProfileId?.split(":", 1)[0],
sessionAuthProfileId: lastProfileId,
config: params.config,
workspaceDir: resolvedWorkspace,
agentDir,
agentId: workspaceResolution.agentId,
thinkingLevel: thinkLevel,
extraParamsOverride: {
...params.streamParams,
fastMode: params.fastMode,
},
});
const attempt = await runEmbeddedAttemptWithBackend({
sessionId: params.sessionId,
@@ -838,6 +876,7 @@ export async function runEmbeddedPiAgent(
// attempt too. Otherwise plugin-owned transports can skip PI auth
// bootstrap but drift back to PI when the attempt is created.
agentHarnessId: agentHarness.id,
runtimePlan,
model: applyAuthHeaderOverride(
applyLocalNoAuthHeaderOverride(effectiveModel, apiKeyInfo),
// When runtime auth exchange produced a different credential

View File

@@ -729,16 +729,23 @@ export async function runEmbeddedAttempt(
let abortSessionForYield: (() => void) | null = null;
let queueYieldInterruptForSession: (() => void) | null = null;
let yieldAbortSettled: Promise<void> | null = null;
const tools = normalizeProviderToolSchemas({
tools: toolsEnabled ? toolsRaw : [],
provider: params.provider,
config: params.config,
const runtimePlanModelContext = {
workspaceDir: effectiveWorkspace,
env: process.env,
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
});
};
const tools =
params.runtimePlan?.tools.normalize(toolsEnabled ? toolsRaw : [], runtimePlanModelContext) ??
normalizeProviderToolSchemas({
tools: toolsEnabled ? toolsRaw : [],
provider: params.provider,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
});
const clientTools = toolsEnabled ? params.clientTools : undefined;
const bundleMcpSessionRuntime = toolsEnabled
? await getOrCreateSessionMcpRuntime({
@@ -794,16 +801,20 @@ export async function runEmbeddedAttempt(
tools: effectiveTools,
clientTools,
});
logProviderToolSchemaDiagnostics({
tools: effectiveTools,
provider: params.provider,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
});
if (params.runtimePlan) {
params.runtimePlan.tools.logDiagnostics(effectiveTools, runtimePlanModelContext);
} else {
logProviderToolSchemaDiagnostics({
tools: effectiveTools,
provider: params.provider,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
});
}
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
@@ -929,22 +940,25 @@ export async function runEmbeddedAttempt(
defaultAgentId,
})
: undefined;
const promptContribution = resolveProviderSystemPromptContribution({
provider: params.provider,
const promptContributionContext = {
config: params.config,
agentDir: params.agentDir,
workspaceDir: effectiveWorkspace,
context: {
config: params.config,
agentDir: params.agentDir,
workspaceDir: effectiveWorkspace,
provider: params.provider,
modelId: params.modelId,
promptMode: effectivePromptMode,
runtimeChannel,
runtimeCapabilities,
agentId: sessionAgentId,
};
const promptContribution =
params.runtimePlan?.prompt.resolveSystemPromptContribution(promptContributionContext) ??
resolveProviderSystemPromptContribution({
provider: params.provider,
modelId: params.modelId,
promptMode: effectivePromptMode,
runtimeChannel,
runtimeCapabilities,
agentId: sessionAgentId,
},
});
config: params.config,
workspaceDir: effectiveWorkspace,
context: promptContributionContext,
});
const builtAppendPrompt =
resolveSystemPromptOverride({
@@ -1045,15 +1059,17 @@ export async function runEmbeddedAttempt(
.then(() => true)
.catch(() => false);
const transcriptPolicy = resolveTranscriptPolicy({
modelApi: params.model?.api,
provider: params.provider,
modelId: params.modelId,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
model: params.model,
});
const transcriptPolicy =
params.runtimePlan?.transcript.resolvePolicy(runtimePlanModelContext) ??
resolveTranscriptPolicy({
modelApi: params.model?.api,
provider: params.provider,
modelId: params.modelId,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
model: params.model,
});
await prewarmSessionFile(params.sessionFile);
sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
@@ -1414,24 +1430,37 @@ export async function runEmbeddedAttempt(
});
}
const resolvedTransport = resolveExplicitSettingsTransport({
settingsManager,
sessionTransport: activeSession.agent.transport,
});
const streamExtraParamsOverride = {
...params.streamParams,
fastMode: params.fastMode,
};
const preparedRuntimeExtraParams = params.runtimePlan?.transport.resolveExtraParams({
extraParamsOverride: streamExtraParamsOverride,
thinkingLevel: params.thinkLevel,
agentId: sessionAgentId,
workspaceDir: effectiveWorkspace,
model: params.model,
resolvedTransport,
});
const { effectiveExtraParams } = applyExtraParamsToAgent(
activeSession.agent,
params.config,
params.provider,
params.modelId,
{
...params.streamParams,
fastMode: params.fastMode,
},
streamExtraParamsOverride,
params.thinkLevel,
sessionAgentId,
effectiveWorkspace,
params.model,
agentDir,
resolveExplicitSettingsTransport({
settingsManager,
sessionTransport: activeSession.agent.transport,
}),
resolvedTransport,
preparedRuntimeExtraParams
? { preparedExtraParams: preparedRuntimeExtraParams }
: undefined,
);
const effectivePromptCacheRetention = resolveCacheRetention(
effectiveExtraParams,
@@ -2762,7 +2791,12 @@ export async function runEmbeddedAttempt(
sessionId: params.sessionId,
provider: params.provider,
model: params.modelId,
resolvedRef: `${params.provider}/${params.modelId}`,
resolvedRef:
params.runtimePlan?.observability.resolvedRef ??
`${params.provider}/${params.modelId}`,
...(params.runtimePlan?.observability.harnessId
? { harnessId: params.runtimePlan.observability.harnessId }
: {}),
assistantTexts,
lastAssistant,
usage: attemptUsage,

View File

@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
inlineDataUriOrphanLeaf,
QUEUED_USER_MESSAGE_MARKER,
structuredOrphanLeaf,
textOrphanLeaf,
} from "../../../../test/helpers/agents/transcript-repair-runtime-contract.js";
import { mergeOrphanedTrailingUserPrompt } from "./attempt.prompt-helpers.js";
import {
DEFAULT_MESSAGE_MERGE_STRATEGY_ID,
registerMessageMergeStrategyForTest,
resolveMessageMergeStrategy,
} from "./message-merge-strategy.js";
let restoreStrategy: (() => void) | undefined;
afterEach(() => {
restoreStrategy?.();
restoreStrategy = undefined;
});
describe("Pi transcript repair runtime contract", () => {
it("merges text orphan leaves into the next prompt with the queued marker", () => {
const result = mergeOrphanedTrailingUserPrompt({
prompt: "newest inbound message",
trigger: "user",
leafMessage: textOrphanLeaf(),
});
expect(result).toEqual({
merged: true,
removeLeaf: true,
prompt: `${QUEUED_USER_MESSAGE_MARKER}\nolder active-turn message\n\nnewest inbound message`,
});
});
it("does not duplicate an orphan leaf that is already present in the next prompt", () => {
const result = mergeOrphanedTrailingUserPrompt({
prompt: "summary\nolder active-turn message\nnewest inbound message",
trigger: "user",
leafMessage: textOrphanLeaf(),
});
expect(result).toEqual({
merged: false,
removeLeaf: true,
prompt: "summary\nolder active-turn message\nnewest inbound message",
});
});
it("preserves structured text and media references before removing the leaf", () => {
const result = mergeOrphanedTrailingUserPrompt({
prompt: "newest inbound message",
trigger: "user",
leafMessage: structuredOrphanLeaf(),
});
expect(result).toEqual({
merged: true,
removeLeaf: true,
prompt:
`${QUEUED_USER_MESSAGE_MARKER}\n` +
"please inspect this\n" +
"[image_url] https://example.test/cat.png\n" +
"[input_audio] https://example.test/cat.wav\n\n" +
"newest inbound message",
});
});
it("summarizes inline data URI media instead of embedding payload bytes", () => {
const result = mergeOrphanedTrailingUserPrompt({
prompt: "newest inbound message",
trigger: "user",
leafMessage: inlineDataUriOrphanLeaf(),
});
expect(result.merged).toBe(true);
expect(result.removeLeaf).toBe(true);
expect(result.prompt).toContain("please inspect this inline image");
expect(result.prompt).toContain("[image_url] inline data URI (image/png, 4118 chars)");
expect(result.prompt).not.toContain("data:");
expect(result.prompt).not.toContain("data:image/png;base64,");
expect(result.prompt).not.toContain("aaaa");
});
it("exposes transcript repair through the active message merge strategy", () => {
const strategy = resolveMessageMergeStrategy();
const result = strategy.mergeOrphanedTrailingUserPrompt({
prompt: "newest inbound message",
trigger: "manual",
leafMessage: textOrphanLeaf("queued via strategy"),
});
expect(strategy.id).toBe("orphan-trailing-user-prompt");
expect(result).toEqual({
merged: true,
removeLeaf: true,
prompt: `${QUEUED_USER_MESSAGE_MARKER}\nqueued via strategy\n\nnewest inbound message`,
});
});
it("allows the active transcript repair strategy to be replaced for adapter contracts", () => {
const mergeOrphanedTrailingUserPromptSpy = vi.fn((params: { prompt: string }) => ({
prompt: `custom strategy: ${params.prompt}`,
merged: false,
removeLeaf: false,
}));
restoreStrategy = registerMessageMergeStrategyForTest({
id: DEFAULT_MESSAGE_MERGE_STRATEGY_ID,
mergeOrphanedTrailingUserPrompt: mergeOrphanedTrailingUserPromptSpy,
});
const result = resolveMessageMergeStrategy().mergeOrphanedTrailingUserPrompt({
prompt: "newest inbound message",
trigger: "manual",
leafMessage: textOrphanLeaf("queued via custom strategy"),
});
expect(mergeOrphanedTrailingUserPromptSpy).toHaveBeenCalledWith({
prompt: "newest inbound message",
trigger: "manual",
leafMessage: textOrphanLeaf("queued via custom strategy"),
});
expect(result).toEqual({
merged: false,
removeLeaf: false,
prompt: "custom strategy: newest inbound message",
});
});
});

View File

@@ -7,6 +7,7 @@ import type { ContextEngine, ContextEnginePromptCacheInfo } from "../../../conte
import type { DiagnosticTraceContext } from "../../../infra/diagnostic-trace-context.js";
import type { PluginHookBeforeAgentStartResult } from "../../../plugins/hook-before-agent-start.types.js";
import type { MessagingToolSend } from "../../pi-embedded-messaging.types.js";
import type { AgentRuntimePlan } from "../../runtime-plan/types.js";
import type { ToolErrorSummary } from "../../tool-error-summary.js";
import type { NormalizedUsage } from "../../usage.js";
import type { EmbeddedRunReplayMetadata, EmbeddedRunReplayState } from "../replay-state.js";
@@ -35,6 +36,8 @@ export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & {
modelId: string;
/** Session-pinned embedded harness id. Prevents runtime hot-switching. */
agentHarnessId?: string;
/** OpenClaw-owned runtime policy prepared by the orchestrator for this attempt. */
runtimePlan?: AgentRuntimePlan;
model: Model<Api>;
authStorage: AuthStorage;
modelRegistry: ModelRegistry;

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
GPT5_CONTRACT_MODEL_ID,
GPT5_PREFIXED_CONTRACT_MODEL_ID,
NON_GPT5_CONTRACT_MODEL_ID,
NON_OPENAI_CONTRACT_PROVIDER_ID,
CODEX_CONTRACT_PROVIDER_ID,
OPENAI_CODEX_CONTRACT_PROVIDER_ID,
OPENAI_CONTRACT_PROVIDER_ID,
openAiPluginPersonalityConfig,
sharedGpt5PersonalityConfig,
} from "../../test/helpers/agents/prompt-overlay-runtime-contract.js";
import { resolveGpt5SystemPromptContribution } from "./gpt5-prompt-overlay.js";
describe("GPT-5 prompt overlay runtime contract", () => {
it("adds the behavior contract and friendly style to OpenAI-family GPT-5 models by default", () => {
const contribution = resolveGpt5SystemPromptContribution({
providerId: OPENAI_CONTRACT_PROVIDER_ID,
modelId: GPT5_CONTRACT_MODEL_ID,
});
expect(contribution?.stablePrefix).toContain("<persona_latch>");
expect(contribution?.sectionOverrides?.interaction_style).toContain(
"This is a live chat, not a memo.",
);
});
it("lets the shared GPT-5 overlay config disable friendly style without removing the behavior contract", () => {
const contribution = resolveGpt5SystemPromptContribution({
providerId: NON_OPENAI_CONTRACT_PROVIDER_ID,
modelId: GPT5_PREFIXED_CONTRACT_MODEL_ID,
config: sharedGpt5PersonalityConfig("off"),
});
expect(contribution?.stablePrefix).toContain("<persona_latch>");
expect(contribution?.sectionOverrides).toEqual({});
});
it("scopes OpenAI plugin personality fallback to OpenAI-family GPT-5 providers", () => {
const openAiContribution = resolveGpt5SystemPromptContribution({
providerId: OPENAI_CODEX_CONTRACT_PROVIDER_ID,
modelId: GPT5_CONTRACT_MODEL_ID,
config: openAiPluginPersonalityConfig("off"),
});
const nonOpenAiContribution = resolveGpt5SystemPromptContribution({
providerId: NON_OPENAI_CONTRACT_PROVIDER_ID,
modelId: GPT5_PREFIXED_CONTRACT_MODEL_ID,
config: openAiPluginPersonalityConfig("off"),
});
expect(openAiContribution?.stablePrefix).toContain("<persona_latch>");
expect(openAiContribution?.sectionOverrides).toEqual({});
expect(nonOpenAiContribution?.stablePrefix).toContain("<persona_latch>");
expect(nonOpenAiContribution?.sectionOverrides?.interaction_style).toContain(
"This is a live chat, not a memo.",
);
});
it("keeps Codex virtual providers in the OpenAI-family personality fallback scope", () => {
const contribution = resolveGpt5SystemPromptContribution({
providerId: CODEX_CONTRACT_PROVIDER_ID,
modelId: GPT5_CONTRACT_MODEL_ID,
config: openAiPluginPersonalityConfig("off"),
});
expect(contribution?.stablePrefix).toContain("<persona_latch>");
expect(contribution?.sectionOverrides).toEqual({});
});
it("does not apply GPT-5 overlays to non-GPT-5 models", () => {
expect(
resolveGpt5SystemPromptContribution({
providerId: OPENAI_CONTRACT_PROVIDER_ID,
modelId: NON_GPT5_CONTRACT_MODEL_ID,
}),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,53 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { normalizeEmbeddedAgentRuntime } from "../pi-embedded-runner/runtime.js";
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
import type { AgentRuntimeAuthPlan } from "./types.js";
const CODEX_HARNESS_AUTH_PROVIDER = "openai-codex";
function resolveHarnessAuthProvider(params: {
harnessId?: string;
harnessRuntime?: string;
}): string | undefined {
const harnessId = normalizeEmbeddedAgentRuntime(params.harnessId);
const runtime = normalizeEmbeddedAgentRuntime(params.harnessRuntime);
return harnessId === "codex" || runtime === "codex" ? CODEX_HARNESS_AUTH_PROVIDER : undefined;
}
export function buildAgentRuntimeAuthPlan(params: {
provider: string;
authProfileProvider?: string;
sessionAuthProfileId?: string;
config?: OpenClawConfig;
workspaceDir?: string;
harnessId?: string;
harnessRuntime?: string;
allowHarnessAuthProfileForwarding?: boolean;
}): AgentRuntimeAuthPlan {
const aliasLookupParams = {
config: params.config,
workspaceDir: params.workspaceDir,
};
const providerForAuth = resolveProviderIdForAuth(params.provider, aliasLookupParams);
const authProfileProviderForAuth = resolveProviderIdForAuth(
params.authProfileProvider ?? params.provider,
aliasLookupParams,
);
const harnessAuthProvider = resolveHarnessAuthProvider(params);
const harnessProviderForAuth = harnessAuthProvider
? resolveProviderIdForAuth(harnessAuthProvider, aliasLookupParams)
: undefined;
const harnessCanForwardProfile =
params.allowHarnessAuthProfileForwarding !== false &&
harnessProviderForAuth &&
harnessProviderForAuth === authProfileProviderForAuth;
const canForwardProfile =
providerForAuth === authProfileProviderForAuth || harnessCanForwardProfile;
return {
providerForAuth,
authProfileProviderForAuth,
...(harnessProviderForAuth ? { harnessAuthProvider: harnessProviderForAuth } : {}),
...(canForwardProfile ? { forwardedAuthProfileId: params.sessionAuthProfileId } : {}),
};
}

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { createParameterFreeTool } from "../../../test/helpers/agents/schema-normalization-runtime-contract.js";
import { buildAgentRuntimePlan } from "./build.js";
describe("AgentRuntimePlan", () => {
it("records resolved model, auth, transport, tool, delivery, and observability policy", () => {
const plan = buildAgentRuntimePlan({
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-responses",
harnessId: "codex",
harnessRuntime: "codex",
authProfileProvider: "openai-codex",
sessionAuthProfileId: "openai-codex:work",
config: {},
workspaceDir: "/tmp/openclaw-runtime-plan",
model: {
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
},
});
expect(plan.auth).toMatchObject({
providerForAuth: "openai",
authProfileProviderForAuth: "openai-codex",
harnessAuthProvider: "openai-codex",
forwardedAuthProfileId: "openai-codex:work",
});
expect(plan.delivery.isSilentPayload({ text: '{"action":"NO_REPLY"}' })).toBe(true);
expect(
plan.delivery.isSilentPayload({
text: '{"action":"NO_REPLY"}',
mediaUrl: "file:///tmp/image.png",
}),
).toBe(false);
expect(plan.transport.extraParams).toMatchObject({
parallel_tool_calls: true,
text_verbosity: "low",
openaiWsWarmup: false,
});
expect(
plan.transport.resolveExtraParams({
extraParamsOverride: { parallel_tool_calls: false },
resolvedTransport: "websocket",
}),
).toMatchObject({
parallel_tool_calls: false,
text_verbosity: "low",
openaiWsWarmup: false,
});
expect(
plan.prompt.resolveSystemPromptContribution({
provider: "openai",
modelId: "gpt-5.4",
promptMode: "full",
})?.stablePrefix,
).toContain("<persona_latch>");
expect(plan.transcript.resolvePolicy()).toEqual(plan.transcript.policy);
expect(
plan.outcome.classifyRunResult({
provider: "openai",
model: "gpt-4.1",
result: {},
}),
).toBeNull();
expect(plan.observability.resolvedRef).toBe("openai/gpt-5.4");
expect(plan.observability.harnessId).toBe("codex");
});
it("keeps OpenClaw-owned tool-schema normalization reachable from the plan", () => {
const plan = buildAgentRuntimePlan({
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-responses",
config: {},
workspaceDir: "/tmp/openclaw-runtime-plan",
model: {
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
},
});
const normalized = plan.tools.normalize([createParameterFreeTool()] as never);
expect(normalized).toHaveLength(1);
expect(normalized[0]?.name).toBe("ping");
expect(normalized[0]?.parameters).toBeTypeOf("object");
});
});

View File

@@ -0,0 +1,203 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { TSchema } from "typebox";
import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import {
resolveProviderFollowupFallbackRoute,
resolveProviderSystemPromptContribution,
} from "../../plugins/provider-runtime.js";
import { resolvePreparedExtraParams } from "../pi-embedded-runner/extra-params.js";
import { classifyEmbeddedPiRunResultForModelFallback } from "../pi-embedded-runner/result-fallback-classifier.js";
import {
logProviderToolSchemaDiagnostics,
normalizeProviderToolSchemas,
} from "../pi-embedded-runner/tool-schema-runtime.js";
import { resolveTranscriptPolicy } from "../transcript-policy.js";
import { buildAgentRuntimeAuthPlan } from "./auth.js";
import type {
AgentRuntimeDeliveryPlan,
AgentRuntimeOutcomePlan,
AgentRuntimePlan,
BuildAgentRuntimeDeliveryPlanParams,
BuildAgentRuntimePlanParams,
} from "./types.js";
function formatResolvedRef(params: { provider: string; modelId: string }): string {
return `${params.provider}/${params.modelId}`;
}
function hasMedia(payload: { mediaUrl?: string; mediaUrls?: string[] }): boolean {
return resolveSendableOutboundReplyParts(payload).hasMedia;
}
export function buildAgentRuntimeDeliveryPlan(
params: BuildAgentRuntimeDeliveryPlanParams,
): AgentRuntimeDeliveryPlan {
return {
isSilentPayload(payload): boolean {
return isSilentReplyPayloadText(payload.text, SILENT_REPLY_TOKEN) && !hasMedia(payload);
},
resolveFollowupRoute(routeParams) {
return resolveProviderFollowupFallbackRoute({
provider: params.provider,
config: params.config,
workspaceDir: params.workspaceDir,
context: {
config: params.config,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
provider: params.provider,
modelId: params.modelId,
payload: routeParams.payload,
originatingChannel: routeParams.originatingChannel,
originatingTo: routeParams.originatingTo,
originRoutable: routeParams.originRoutable,
dispatcherAvailable: routeParams.dispatcherAvailable,
},
});
},
};
}
export function buildAgentRuntimeOutcomePlan(): AgentRuntimeOutcomePlan {
return {
classifyRunResult: classifyEmbeddedPiRunResultForModelFallback,
};
}
export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): AgentRuntimePlan {
const modelApi = params.modelApi ?? params.model?.api ?? undefined;
const transport = params.resolvedTransport;
const auth = buildAgentRuntimeAuthPlan({
provider: params.provider,
authProfileProvider: params.authProfileProvider,
sessionAuthProfileId: params.sessionAuthProfileId,
config: params.config,
workspaceDir: params.workspaceDir,
harnessId: params.harnessId,
harnessRuntime: params.harnessRuntime,
allowHarnessAuthProfileForwarding: params.allowHarnessAuthProfileForwarding,
});
const resolvedRef = {
provider: params.provider,
modelId: params.modelId,
...(modelApi ? { modelApi } : {}),
...(params.harnessId ? { harnessId: params.harnessId } : {}),
...(transport ? { transport } : {}),
};
const toolContext = {
provider: params.provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: process.env,
modelId: params.modelId,
modelApi,
model: params.model,
};
const resolveToolContext = (overrides?: {
workspaceDir?: string;
modelApi?: string;
model?: BuildAgentRuntimePlanParams["model"];
}) => ({
...toolContext,
...(overrides?.workspaceDir !== undefined ? { workspaceDir: overrides.workspaceDir } : {}),
...(overrides?.modelApi !== undefined ? { modelApi: overrides.modelApi } : {}),
...(overrides?.model !== undefined ? { model: overrides.model } : {}),
});
const resolveTranscriptRuntimePolicy = (overrides?: {
workspaceDir?: string;
modelApi?: string;
model?: BuildAgentRuntimePlanParams["model"];
}) =>
resolveTranscriptPolicy({
provider: params.provider,
modelId: params.modelId,
config: params.config,
workspaceDir: overrides?.workspaceDir ?? params.workspaceDir,
env: process.env,
modelApi: overrides?.modelApi ?? modelApi,
model: overrides?.model ?? params.model,
});
const resolveTransportExtraParams = (
overrides: Parameters<AgentRuntimePlan["transport"]["resolveExtraParams"]>[0] = {},
) =>
resolvePreparedExtraParams({
cfg: params.config,
provider: params.provider,
modelId: params.modelId,
agentDir: params.agentDir,
workspaceDir: overrides.workspaceDir ?? params.workspaceDir,
extraParamsOverride: overrides.extraParamsOverride ?? params.extraParamsOverride,
thinkingLevel: overrides.thinkingLevel ?? params.thinkingLevel,
agentId: overrides.agentId ?? params.agentId,
model: overrides.model ?? params.model,
resolvedTransport: overrides.resolvedTransport ?? transport,
});
return {
resolvedRef,
auth,
prompt: {
provider: params.provider,
modelId: params.modelId,
resolveSystemPromptContribution(context) {
return resolveProviderSystemPromptContribution({
provider: params.provider,
config: params.config,
workspaceDir: context.workspaceDir ?? params.workspaceDir,
context,
});
},
},
tools: {
normalize<TSchemaType extends TSchema = TSchema, TResult = unknown>(
tools: AgentTool<TSchemaType, TResult>[],
overrides?: {
workspaceDir?: string;
modelApi?: string;
model?: BuildAgentRuntimePlanParams["model"];
},
): AgentTool<TSchemaType, TResult>[] {
return normalizeProviderToolSchemas({
...resolveToolContext(overrides),
tools,
});
},
logDiagnostics(
tools: AgentTool[],
overrides?: {
workspaceDir?: string;
modelApi?: string;
model?: BuildAgentRuntimePlanParams["model"];
},
): void {
logProviderToolSchemaDiagnostics({
...resolveToolContext(overrides),
tools,
});
},
},
transcript: {
policy: resolveTranscriptRuntimePolicy(),
resolvePolicy: resolveTranscriptRuntimePolicy,
},
delivery: buildAgentRuntimeDeliveryPlan(params),
outcome: buildAgentRuntimeOutcomePlan(),
transport: {
extraParams: resolveTransportExtraParams(),
resolveExtraParams: resolveTransportExtraParams,
},
observability: {
resolvedRef: formatResolvedRef({
provider: params.provider,
modelId: params.modelId,
}),
provider: params.provider,
modelId: params.modelId,
...(modelApi ? { modelApi } : {}),
...(params.harnessId ? { harnessId: params.harnessId } : {}),
...(auth.forwardedAuthProfileId ? { authProfileId: auth.forwardedAuthProfileId } : {}),
...(transport ? { transport } : {}),
},
};
}

View File

@@ -0,0 +1,18 @@
export { buildAgentRuntimeAuthPlan } from "./auth.js";
export {
buildAgentRuntimeDeliveryPlan,
buildAgentRuntimeOutcomePlan,
buildAgentRuntimePlan,
} from "./build.js";
export type {
AgentRuntimeAuthPlan,
AgentRuntimeDeliveryPlan,
AgentRuntimeOutcomePlan,
AgentRuntimePlan,
AgentRuntimePromptPlan,
AgentRuntimeResolvedRef,
AgentRuntimeToolPlan,
AgentRuntimeTransportPlan,
BuildAgentRuntimeDeliveryPlanParams,
BuildAgentRuntimePlanParams,
} from "./types.js";

View File

@@ -0,0 +1,204 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { TSchema } from "typebox";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import type { FailoverReason } from "../pi-embedded-helpers/types.js";
import type { PromptMode } from "../system-prompt.types.js";
export type AgentRuntimeTransport = "sse" | "websocket" | "auto";
export type AgentRuntimeSystemPromptSectionId =
| "interaction_style"
| "tool_call_style"
| "execution_bias";
export type AgentRuntimeSystemPromptContribution = {
stablePrefix?: string;
dynamicSuffix?: string;
sectionOverrides?: Partial<Record<AgentRuntimeSystemPromptSectionId, string>>;
};
export type AgentRuntimeSystemPromptContributionContext = {
config?: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
provider: string;
modelId: string;
promptMode: PromptMode;
runtimeChannel?: string;
runtimeCapabilities?: string[];
agentId?: string;
};
export type AgentRuntimeFollowupFallbackRouteResult = {
route?: "origin" | "dispatcher" | "drop";
reason?: string;
};
export type AgentRuntimeToolCallIdMode = "strict" | "strict9";
export type AgentRuntimeTranscriptPolicy = {
sanitizeMode: "full" | "images-only";
sanitizeToolCallIds: boolean;
toolCallIdMode?: AgentRuntimeToolCallIdMode;
preserveNativeAnthropicToolUseIds: boolean;
repairToolUseResultPairing: boolean;
preserveSignatures: boolean;
sanitizeThoughtSignatures?: {
allowBase64Only?: boolean;
includeCamelCase?: boolean;
};
sanitizeThinkingSignatures: boolean;
dropThinkingBlocks: boolean;
applyGoogleTurnOrdering: boolean;
validateGeminiTurns: boolean;
validateAnthropicTurns: boolean;
allowSyntheticToolResults: boolean;
};
export type AgentRuntimeOutcomeClassification =
| {
message: string;
reason?: FailoverReason;
status?: number;
code?: string;
rawError?: string;
}
| {
error: unknown;
}
| null
| undefined;
export type AgentRuntimeOutcomeClassifier = (params: {
provider: string;
model: string;
result: unknown;
hasDirectlySentBlockReply?: boolean;
hasBlockReplyPipelineOutput?: boolean;
}) => AgentRuntimeOutcomeClassification;
export type AgentRuntimeResolvedRef = {
provider: string;
modelId: string;
modelApi?: string;
harnessId?: string;
transport?: AgentRuntimeTransport;
};
export type AgentRuntimeAuthPlan = {
providerForAuth: string;
authProfileProviderForAuth: string;
harnessAuthProvider?: string;
forwardedAuthProfileId?: string;
};
export type AgentRuntimePromptPlan = {
provider: string;
modelId: string;
resolveSystemPromptContribution(
context: AgentRuntimeSystemPromptContributionContext,
): AgentRuntimeSystemPromptContribution | undefined;
};
export type AgentRuntimeToolPlan = {
normalize<TSchemaType extends TSchema = TSchema, TResult = unknown>(
tools: AgentTool<TSchemaType, TResult>[],
params?: {
workspaceDir?: string;
modelApi?: string;
model?: ProviderRuntimeModel;
},
): AgentTool<TSchemaType, TResult>[];
logDiagnostics(
tools: AgentTool[],
params?: {
workspaceDir?: string;
modelApi?: string;
model?: ProviderRuntimeModel;
},
): void;
};
export type AgentRuntimeDeliveryPlan = {
isSilentPayload(payload: Pick<ReplyPayload, "text" | "mediaUrl" | "mediaUrls">): boolean;
resolveFollowupRoute(params: {
payload: ReplyPayload;
originatingChannel?: string;
originatingTo?: string;
originRoutable: boolean;
dispatcherAvailable: boolean;
}): AgentRuntimeFollowupFallbackRouteResult | undefined;
};
export type AgentRuntimeOutcomePlan = {
classifyRunResult: AgentRuntimeOutcomeClassifier;
};
export type AgentRuntimeTransportPlan = {
extraParams: Record<string, unknown>;
resolveExtraParams(params?: {
extraParamsOverride?: Record<string, unknown>;
thinkingLevel?: ThinkLevel;
agentId?: string;
workspaceDir?: string;
model?: ProviderRuntimeModel;
resolvedTransport?: AgentRuntimeTransport;
}): Record<string, unknown>;
};
export type AgentRuntimePlan = {
resolvedRef: AgentRuntimeResolvedRef;
auth: AgentRuntimeAuthPlan;
prompt: AgentRuntimePromptPlan;
tools: AgentRuntimeToolPlan;
transcript: {
policy: AgentRuntimeTranscriptPolicy;
resolvePolicy(params?: {
workspaceDir?: string;
modelApi?: string;
model?: ProviderRuntimeModel;
}): AgentRuntimeTranscriptPolicy;
};
delivery: AgentRuntimeDeliveryPlan;
outcome: AgentRuntimeOutcomePlan;
transport: AgentRuntimeTransportPlan;
observability: {
resolvedRef: string;
provider: string;
modelId: string;
modelApi?: string;
harnessId?: string;
authProfileId?: string;
transport?: AgentRuntimeTransport;
};
};
export type BuildAgentRuntimeDeliveryPlanParams = {
config?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
provider: string;
modelId: string;
};
export type BuildAgentRuntimePlanParams = {
config?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
provider: string;
modelId: string;
model?: ProviderRuntimeModel;
modelApi?: string | null;
harnessId?: string;
harnessRuntime?: string;
allowHarnessAuthProfileForwarding?: boolean;
authProfileProvider?: string;
sessionAuthProfileId?: string;
agentId?: string;
thinkingLevel?: ThinkLevel;
extraParamsOverride?: Record<string, unknown>;
resolvedTransport?: AgentRuntimeTransport;
};

View File

@@ -0,0 +1,107 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import {
createNativeOpenAIResponsesModel,
createParameterFreeTool,
createPermissiveTool,
createStrictCompatibleTool,
normalizedParameterFreeSchema,
} from "../../test/helpers/agents/schema-normalization-runtime-contract.js";
import { buildProviderToolCompatFamilyHooks } from "../plugin-sdk/provider-tools.js";
import { buildOpenAIResponsesParams } from "./openai-transport-stream.js";
import { convertTools as convertWebSocketTools } from "./openai-ws-message-conversion.js";
import { createOpenAIResponsesContextManagementWrapper } from "./pi-embedded-runner/openai-stream-wrappers.js";
describe("OpenAI transport schema normalization runtime contract", () => {
it("keeps HTTP Responses and WebSocket strict decisions aligned for the same tool set", () => {
const tools = [createStrictCompatibleTool(), createPermissiveTool()] as never;
const httpParams = buildOpenAIResponsesParams(
createNativeOpenAIResponsesModel() as never,
{ systemPrompt: "system", messages: [], tools } as never,
undefined,
) as { tools?: Array<{ strict?: boolean; parameters?: unknown }> };
const wsTools = convertWebSocketTools(tools, { strict: true });
expect(httpParams.tools?.map((tool) => tool.strict)).toEqual([false, false]);
expect(wsTools.map((tool) => tool.strict)).toEqual([false, false]);
});
it("normalizes parameter-free tool schemas to the same strict-compatible object shape for HTTP Responses and WebSocket", () => {
const tools = [createParameterFreeTool()] as never;
const httpParams = buildOpenAIResponsesParams(
createNativeOpenAIResponsesModel() as never,
{ systemPrompt: "system", messages: [], tools } as never,
undefined,
) as { tools?: Array<{ strict?: boolean; parameters?: unknown }> };
const wsTools = convertWebSocketTools(tools, { strict: true });
const normalizedSchema = normalizedParameterFreeSchema();
expect(httpParams.tools?.[0]?.strict).toBe(true);
expect(wsTools[0]?.strict).toBe(true);
expect(httpParams.tools?.[0]?.parameters).toEqual(normalizedSchema);
expect(wsTools[0]?.parameters).toEqual(normalizedSchema);
});
it("keeps provider-prepared parameter-free schemas strict-compatible across HTTP Responses and WebSocket", () => {
const hooks = buildProviderToolCompatFamilyHooks("openai");
const tools = hooks.normalizeToolSchemas({
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-responses",
tools: [createParameterFreeTool()] as never,
}) as never;
const httpParams = buildOpenAIResponsesParams(
createNativeOpenAIResponsesModel() as never,
{ systemPrompt: "system", messages: [], tools } as never,
undefined,
) as { tools?: Array<{ strict?: boolean; parameters?: unknown }> };
const wsTools = convertWebSocketTools(tools, { strict: true });
const normalizedSchema = normalizedParameterFreeSchema();
expect(httpParams.tools?.[0]?.strict).toBe(true);
expect(wsTools[0]?.strict).toBe(true);
expect(httpParams.tools?.[0]?.parameters).toEqual(normalizedSchema);
expect(wsTools[0]?.parameters).toEqual(normalizedSchema);
});
it("passes prepared executable schemas through compaction-triggered Responses requests", () => {
const hooks = buildProviderToolCompatFamilyHooks("openai");
const tools = hooks.normalizeToolSchemas({
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-responses",
tools: [createParameterFreeTool()] as never,
}) as never;
const model = createNativeOpenAIResponsesModel() as never;
let payload:
| { context_management?: unknown; tools?: Array<{ parameters?: unknown }> }
| undefined;
const baseStreamFn: StreamFn = (modelArg, contextArg, optionsArg) => {
payload = buildOpenAIResponsesParams(
modelArg,
{
...(contextArg as unknown as Record<string, unknown>),
systemPrompt: "system",
messages: [],
tools,
} as never,
optionsArg as never,
) as typeof payload;
optionsArg?.onPayload?.(payload, modelArg);
return {} as ReturnType<StreamFn>;
};
const streamFn = createOpenAIResponsesContextManagementWrapper(baseStreamFn, {
responsesServerCompaction: true,
});
void streamFn(model, { systemPrompt: "system", messages: [], tools } as never, {});
expect(payload?.context_management).toEqual([
{
type: "compaction",
compact_threshold: 140_000,
},
]);
expect(payload?.tools?.[0]?.parameters).toEqual(normalizedParameterFreeSchema());
});
});

View File

@@ -0,0 +1,239 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
GPT_PARALLEL_TOOL_CALLS_PAYLOAD_APIS,
NON_OPENAI_GPT5_TRANSPORT_CASE,
OPENAI_GPT5_TRANSPORT_DEFAULT_CASES,
OPENAI_GPT5_TRANSPORT_DEFAULTS,
UNRELATED_TOOL_CALLS_PAYLOAD_APIS,
} from "../../test/helpers/agents/transport-params-runtime-contract.js";
import {
__testing as extraParamsTesting,
applyExtraParamsToAgent,
resolveExtraParams,
resolvePreparedExtraParams,
} from "./pi-embedded-runner/extra-params.js";
import { createOpenAIThinkingLevelWrapper } from "./pi-embedded-runner/openai-stream-wrappers.js";
import { supportsGptParallelToolCallsPayload } from "./provider-api-families.js";
beforeEach(() => {
installNoopProviderRuntimeDeps();
});
afterEach(() => {
extraParamsTesting.resetProviderRuntimeDepsForTest();
});
describe("transport params runtime contract (Pi/OpenAI path)", () => {
it.each(OPENAI_GPT5_TRANSPORT_DEFAULT_CASES)(
"applies OpenAI GPT-5 transport defaults for $provider/$modelId",
({ provider, modelId }) => {
expect(resolveExtraParams({ cfg: undefined, provider, modelId })).toEqual(
OPENAI_GPT5_TRANSPORT_DEFAULTS,
);
},
);
it("does not leak OpenAI GPT-5 defaults to non-OpenAI providers", () => {
expect(
resolveExtraParams({
cfg: undefined,
provider: NON_OPENAI_GPT5_TRANSPORT_CASE.provider,
modelId: NON_OPENAI_GPT5_TRANSPORT_CASE.modelId,
}),
).toBeUndefined();
});
it("normalizes aliased caller params without losing explicit overrides", () => {
const cfg = {
agents: {
defaults: {
models: {
"openai/gpt-5.4": {
params: {
parallelToolCalls: false,
textVerbosity: "medium",
cached_content: "conversation-cache",
openaiWsWarmup: true,
},
},
},
},
},
};
expect(resolveExtraParams({ cfg, provider: "openai", modelId: "gpt-5.4" })).toEqual({
parallel_tool_calls: false,
text_verbosity: "medium",
cachedContent: "conversation-cache",
openaiWsWarmup: true,
});
});
it.each(GPT_PARALLEL_TOOL_CALLS_PAYLOAD_APIS)(
"advertises %s as accepting the GPT parallel_tool_calls payload patch",
(api) => {
expect(supportsGptParallelToolCallsPayload(api)).toBe(true);
},
);
it.each(UNRELATED_TOOL_CALLS_PAYLOAD_APIS)(
"does not advertise %s as accepting the GPT parallel_tool_calls payload patch",
(api) => {
expect(supportsGptParallelToolCallsPayload(api)).toBe(false);
},
);
it("injects parallel_tool_calls into openai-codex Responses payloads", () => {
const payload = runPayloadMutation({
applyProvider: "openai-codex",
applyModelId: "gpt-5.4",
model: {
api: "openai-codex-responses",
provider: "openai-codex",
id: "gpt-5.4",
} as Model<"openai-codex-responses">,
});
expect(payload.parallel_tool_calls).toBe(true);
});
it("propagates OpenAI GPT-5 warmup default through stream options", () => {
const { agent, calls } = createOptionsCaptureAgent();
applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5.4");
void agent.streamFn?.(
{
api: "openai-responses",
provider: "openai",
id: "gpt-5.4",
} as Model<"openai-responses">,
{ messages: [] },
{},
);
expect(calls).toEqual([
expect.objectContaining({
openaiWsWarmup: false,
}),
]);
});
it("maps OpenAI GPT-5 thinking level into Responses reasoning effort payloads", () => {
extraParamsTesting.setProviderRuntimeDepsForTest({
prepareProviderExtraParams: () => undefined,
resolveProviderExtraParamsForTransport: () => undefined,
wrapProviderStreamFn: (params) =>
createOpenAIThinkingLevelWrapper(params.context.streamFn, params.context.thinkingLevel),
});
const payload = runPayloadMutation({
applyProvider: "openai-codex",
applyModelId: "gpt-5.4",
thinkingLevel: "high",
model: {
api: "openai-codex-responses",
provider: "openai-codex",
id: "gpt-5.4",
baseUrl: "https://chatgpt.com/backend-api",
} as Model<"openai-codex-responses">,
payload: { reasoning: { effort: "none", summary: "auto" } },
});
expect(payload.reasoning).toEqual({ effort: "high", summary: "auto" });
});
it("composes provider preparation before transport patch resolution", () => {
const resolveProviderExtraParamsForTransport = vi.fn(() => ({
patch: {
parallel_tool_calls: false,
transportHookApplied: true,
},
}));
extraParamsTesting.setProviderRuntimeDepsForTest({
prepareProviderExtraParams: (params) => ({
...params.context.extraParams,
transport: "websocket",
preparedByProvider: true,
}),
resolveProviderExtraParamsForTransport,
wrapProviderStreamFn: (params) => params.context.streamFn,
});
const prepared = resolvePreparedExtraParams({
cfg: undefined,
provider: "openai",
modelId: "gpt-5.4",
thinkingLevel: "high",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5.4",
} as Model<"openai-responses">,
});
expect(prepared).toMatchObject({
transport: "websocket",
preparedByProvider: true,
parallel_tool_calls: false,
transportHookApplied: true,
});
expect(resolveProviderExtraParamsForTransport).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.objectContaining({
extraParams: expect.objectContaining({
preparedByProvider: true,
}),
transport: "websocket",
}),
}),
);
});
});
function runPayloadMutation(params: {
applyProvider: string;
applyModelId: string;
model: Model<"openai-codex-responses"> | Model<"openai-responses">;
thinkingLevel?: Parameters<typeof applyExtraParamsToAgent>[5];
payload?: Record<string, unknown>;
}): Record<string, unknown> {
const payload: Record<string, unknown> = params.payload ?? {};
const baseStreamFn: StreamFn = (model, _context, options) => {
options?.onPayload?.(payload, model);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(
agent,
undefined,
params.applyProvider,
params.applyModelId,
undefined,
params.thinkingLevel,
);
const context: Context = { messages: [] };
void agent.streamFn?.(params.model, context, {} as SimpleStreamOptions);
return payload;
}
function installNoopProviderRuntimeDeps() {
extraParamsTesting.setProviderRuntimeDepsForTest({
prepareProviderExtraParams: () => undefined,
resolveProviderExtraParamsForTransport: () => undefined,
wrapProviderStreamFn: (params) => params.context.streamFn,
});
}
function createOptionsCaptureAgent() {
const calls: Array<(SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined> = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
calls.push(options as (SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined);
return {} as ReturnType<StreamFn>;
};
return {
calls,
agent: { streamFn: baseStreamFn },
};
}

View File

@@ -26,9 +26,9 @@ import {
isTransientHttpError,
} from "../../agents/pi-embedded-helpers.js";
import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers/sanitize-user-facing-text.js";
import { classifyEmbeddedPiRunResultForModelFallback } from "../../agents/pi-embedded-runner/result-fallback-classifier.js";
import { isLikelyExecutionAckPrompt } from "../../agents/pi-embedded-runner/run/incomplete-turn.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { buildAgentRuntimeOutcomePlan } from "../../agents/runtime-plan/build.js";
import {
resolveGroupSessionKey,
resolveSessionTranscriptPath,
@@ -885,11 +885,12 @@ export async function runAgentTurnWithFallback(params: {
})
: undefined;
const onToolResult = params.opts?.onToolResult;
const outcomePlan = buildAgentRuntimeOutcomePlan();
const fallbackResult = await runWithModelFallback<EmbeddedAgentRunResult>({
...resolveModelFallbackOptions(params.followupRun.run),
runId,
classifyResult: async ({ result, provider, model }) => {
const classification = classifyEmbeddedPiRunResultForModelFallback({
const classification = outcomePlan.classifyRunResult({
result,
provider,
model,

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { DELIVERY_NO_REPLY_RUNTIME_CONTRACT } from "../../../test/helpers/agents/delivery-no-reply-runtime-contract.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
@@ -1068,7 +1069,7 @@ describe("createFollowupRunner bootstrap warning dedupe", () => {
});
});
describe("createFollowupRunner messaging tool dedupe", () => {
describe("createFollowupRunner messaging delivery and dedupe", () => {
function createMessagingDedupeRunner(
onBlockReply: (payload: unknown) => Promise<void>,
overrides: Partial<{
@@ -1410,6 +1411,88 @@ describe("createFollowupRunner messaging tool dedupe", () => {
expect(onBlockReply).not.toHaveBeenCalled();
});
it("suppresses exact NO_REPLY followups without origin or dispatcher delivery", async () => {
const typing = createMockTypingController();
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: ` ${DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText} ` }],
meta: {},
});
const runner = createFollowupRunner({
typing,
typingMode: "instant",
defaultModel: "anthropic/claude-opus-4-6",
});
await runner(createQueuedRun({ originatingChannel: undefined, originatingTo: undefined }));
expect(routeReplyMock).not.toHaveBeenCalled();
expect(typing.markRunComplete).toHaveBeenCalled();
expect(typing.markDispatchIdle).toHaveBeenCalled();
});
it("suppresses JSON NO_REPLY followups without origin or dispatcher delivery", async () => {
const typing = createMockTypingController();
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.jsonSilentText }],
meta: {},
});
const runner = createFollowupRunner({
typing,
typingMode: "instant",
defaultModel: "anthropic/claude-opus-4-6",
});
await runner(createQueuedRun({ originatingChannel: undefined, originatingTo: undefined }));
expect(routeReplyMock).not.toHaveBeenCalled();
expect(typing.markRunComplete).toHaveBeenCalled();
expect(typing.markDispatchIdle).toHaveBeenCalled();
});
it("keeps NO_REPLY followups with media deliverable", async () => {
const { onBlockReply } = await runMessagingCase({
agentResult: {
payloads: [
{
text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText,
mediaUrl: "file:///tmp/followup.png",
},
],
},
queued: {
...baseQueuedRun("webchat"),
originatingChannel: undefined,
originatingTo: undefined,
} as FollowupRun,
});
expect(routeReplyMock).not.toHaveBeenCalled();
expect(onBlockReply).toHaveBeenCalledTimes(1);
expect(onBlockReply).toHaveBeenCalledWith(
expect.objectContaining({
text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText,
mediaUrl: "file:///tmp/followup.png",
}),
);
});
it("falls back to dispatcher when successful output has no complete origin route", async () => {
const { onBlockReply } = await runMessagingCase({
agentResult: { payloads: [{ text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.dispatcherText }] },
queued: {
...baseQueuedRun("webchat"),
originatingChannel: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.originChannel,
originatingTo: undefined,
} as FollowupRun,
});
expect(routeReplyMock).not.toHaveBeenCalled();
expect(onBlockReply).toHaveBeenCalledTimes(1);
expect(onBlockReply).toHaveBeenCalledWith(
expect.objectContaining({ text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.dispatcherText }),
);
});
it("falls back to dispatcher when same-channel origin routing fails", async () => {
routeReplyMock.mockResolvedValueOnce({
ok: false,

View File

@@ -9,18 +9,19 @@ import { resolveContextTokensForModel } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { classifyEmbeddedPiRunResultForModelFallback } from "../../agents/pi-embedded-runner/result-fallback-classifier.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import {
buildAgentRuntimeDeliveryPlan,
buildAgentRuntimeOutcomePlan,
} from "../../agents/runtime-plan/build.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js";
import { logVerbose } from "../../globals.js";
import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { resolveProviderFollowupFallbackRoute } from "../../plugins/provider-runtime.js";
import { defaultRuntime } from "../../runtime.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { runPreflightCompactionIfNeeded } from "./agent-runner-memory.js";
import {
@@ -84,6 +85,22 @@ export function createFollowupRunner(params: {
const { originatingChannel, originatingTo } = queued;
const runtimeConfig = resolveQueuedReplyRuntimeConfig(queued.run.config);
const shouldRouteToOriginating = isRoutableChannel(originatingChannel) && originatingTo;
const deliveryPlan = buildAgentRuntimeDeliveryPlan({
provider: resolvedRun.provider,
modelId: resolvedRun.modelId,
config: runtimeConfig,
workspaceDir: queued.run.workspaceDir,
agentDir: queued.run.agentDir,
});
const sendablePayloads = payloads.filter(
(payload): payload is ReplyPayload =>
hasOutboundReplyContent(payload) && !deliveryPlan.isSilentPayload(payload),
);
if (sendablePayloads.length === 0) {
return;
}
if (!shouldRouteToOriginating && !opts?.onBlockReply) {
defaultRuntime.error?.(
@@ -94,32 +111,13 @@ export function createFollowupRunner(params: {
let crossChannelRouteFailureNeedsNotice = false;
let routedAnyCrossChannelPayloadToOrigin = false;
for (const payload of payloads) {
if (!payload || !hasOutboundReplyContent(payload)) {
continue;
}
if (
isSilentReplyText(payload.text, SILENT_REPLY_TOKEN) &&
!resolveSendableOutboundReplyParts(payload).hasMedia
) {
continue;
}
const providerRoute = resolveProviderFollowupFallbackRoute({
provider: resolvedRun.provider,
config: runtimeConfig,
workspaceDir: queued.run.workspaceDir,
context: {
config: runtimeConfig,
agentDir: queued.run.agentDir,
workspaceDir: queued.run.workspaceDir,
provider: resolvedRun.provider,
modelId: resolvedRun.modelId,
payload,
originatingChannel,
originatingTo,
originRoutable: Boolean(shouldRouteToOriginating),
dispatcherAvailable: Boolean(opts?.onBlockReply),
},
for (const payload of sendablePayloads) {
const providerRoute = deliveryPlan.resolveFollowupRoute({
payload,
originatingChannel,
originatingTo,
originRoutable: Boolean(shouldRouteToOriginating),
dispatcherAvailable: Boolean(opts?.onBlockReply),
});
if (providerRoute?.route === "drop") {
logVerbose(
@@ -263,6 +261,7 @@ export function createFollowupRunner(params: {
);
replyOperation.setPhase("running");
try {
const outcomePlan = buildAgentRuntimeOutcomePlan();
const fallbackResult = await runWithModelFallback<EmbeddedAgentRunResult>({
cfg: runtimeConfig,
provider: run.provider,
@@ -275,7 +274,7 @@ export function createFollowupRunner(params: {
sessionKey: run.sessionKey,
}),
classifyResult: ({ result, provider, model }) =>
classifyEmbeddedPiRunResultForModelFallback({ result, provider, model }),
outcomePlan.classifyRunResult({ result, provider, model }),
run: async (provider, model, runOptions) => {
const authProfile = resolveRunAuthProfile(run, provider, { config: runtimeConfig });
let attemptCompactionCount = 0;

View File

@@ -74,9 +74,9 @@ vi.mock("./body.js", () => ({
}));
vi.mock("./groups.js", () => ({
buildDirectChatContext: vi.fn().mockReturnValue(""),
buildGroupIntro: vi.fn().mockReturnValue(""),
buildGroupChatContext: vi.fn().mockReturnValue(""),
buildDirectChatContext: vi.fn().mockReturnValue(""),
}));
vi.mock("./inbound-meta.js", () => ({

View File

@@ -225,7 +225,7 @@ export function normalizeLegacyOpenAICodexModelsAddMetadata(
) {
providerChanged = true;
const safeProviderId = sanitizeForLog(providerId);
const safeModelId = sanitizeForLog(String(model.id));
const safeModelId = sanitizeForLog(model.id);
changes.push(
`Marked models.providers.${safeProviderId}.models.${safeModelId} as /models add metadata so official OpenAI Codex metadata can override it.`,
);

View File

@@ -13,6 +13,7 @@ export * from "../agents/model-selection.js";
export * from "../agents/simple-completion-runtime.js";
export * from "../agents/pi-embedded-block-chunker.js";
export * from "../agents/pi-embedded-utils.js";
export * from "../agents/provider-auth-aliases.js";
export * from "../agents/provider-id.js";
export * from "../agents/sandbox-paths.js";
export * from "../agents/schema/typebox.js";

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
createNativeOpenAICodexResponsesModel,
createNativeOpenAIResponsesModel,
createParameterFreeTool,
createPermissiveTool,
createProxyOpenAIResponsesModel,
normalizedParameterFreeSchema,
} from "../../test/helpers/agents/schema-normalization-runtime-contract.js";
import { buildProviderToolCompatFamilyHooks } from "./provider-tools.js";
describe("OpenAI-family schema normalization runtime contract", () => {
const hooks = buildProviderToolCompatFamilyHooks("openai");
it("normalizes parameter-free schemas for native OpenAI Responses tools", () => {
const normalized = hooks.normalizeToolSchemas({
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-responses",
model: createNativeOpenAIResponsesModel() as never,
tools: [createParameterFreeTool()] as never,
});
expect(normalized[0]?.parameters).toEqual(normalizedParameterFreeSchema());
});
it("normalizes parameter-free schemas for native OpenAI Codex Responses tools", () => {
const normalized = hooks.normalizeToolSchemas({
provider: "openai-codex",
modelId: "gpt-5.4",
modelApi: "openai-codex-responses",
model: createNativeOpenAICodexResponsesModel() as never,
tools: [createParameterFreeTool()] as never,
});
expect(normalized[0]?.parameters).toEqual(normalizedParameterFreeSchema());
});
it("does not apply native strict normalization to proxy-like OpenAI routes", () => {
const tools = [createParameterFreeTool()] as never;
const normalized = hooks.normalizeToolSchemas({
provider: "openai",
modelId: "custom-gpt",
modelApi: "openai-responses",
model: createProxyOpenAIResponsesModel() as never,
tools,
});
expect(normalized).toBe(tools);
});
it("keeps permissive schemas observable for transport strict:false downgrade", () => {
const tool = createPermissiveTool();
const normalized = hooks.normalizeToolSchemas({
provider: "openai-codex",
modelId: "gpt-5.4",
modelApi: "openai-codex-responses",
model: createNativeOpenAICodexResponsesModel() as never,
tools: [tool] as never,
});
expect(normalized[0]?.parameters).toEqual(tool.parameters);
expect(
hooks.inspectToolSchemas({
provider: "openai-codex",
modelId: "gpt-5.4",
modelApi: "openai-codex-responses",
model: createNativeOpenAICodexResponsesModel() as never,
tools: [tool] as never,
}),
).toEqual([]);
});
});

View File

@@ -200,6 +200,11 @@ export type PluginHookLlmOutputEvent = {
* names collapse to just the model id.
*/
resolvedRef?: string;
/**
* Harness/backend responsible for the model loop. Kept separate from
* `resolvedRef` so provider/model consumers keep a stable parse contract.
*/
harnessId?: string;
assistantTexts: string[];
lastAssistant?: unknown;
usage?: {

View File

@@ -0,0 +1,60 @@
import {
resolveProviderIdForAuth,
type ProviderAuthAliasLookupParams,
} from "../../../src/agents/provider-auth-aliases.js";
import type { PluginManifestRegistry } from "../../../src/plugins/manifest-registry.js";
export const AUTH_PROFILE_RUNTIME_CONTRACT = {
sessionId: "session-auth-contract",
sessionKey: "agent:main:auth-contract",
runId: "run-auth-contract",
workspacePrompt: "continue with the bound Codex profile",
openAiProvider: "openai",
openAiCodexProvider: "openai-codex",
codexCliProvider: "codex-cli",
codexHarnessProvider: "codex",
claudeCliProvider: "claude-cli",
openAiProfileId: "openai:work",
openAiCodexProfileId: "openai-codex:work",
anthropicProfileId: "anthropic:work",
} as const;
export function createAuthAliasManifestRegistry(): PluginManifestRegistry {
return {
plugins: [
{
id: "openai",
origin: "bundled",
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
rootDir: "/tmp/openclaw-auth-contract-plugin",
source: "test",
manifestPath: "/tmp/openclaw-auth-contract-plugin/plugin.json",
providerAuthChoices: [
{
provider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
method: "oauth",
choiceId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider,
deprecatedChoiceIds: [AUTH_PROFILE_RUNTIME_CONTRACT.codexCliProvider],
},
],
},
],
diagnostics: [],
};
}
export function expectedForwardedAuthProfile(params: {
provider: string;
authProfileProvider: string;
aliasLookupParams: ProviderAuthAliasLookupParams;
sessionAuthProfileId: string | undefined;
}): string | undefined {
return resolveProviderIdForAuth(params.provider, params.aliasLookupParams) ===
resolveProviderIdForAuth(params.authProfileProvider, params.aliasLookupParams)
? params.sessionAuthProfileId
: undefined;
}

View File

@@ -0,0 +1,12 @@
export const DELIVERY_NO_REPLY_RUNTIME_CONTRACT = {
sessionId: "session-delivery-contract",
sessionKey: "agent:main:delivery-contract",
runId: "run-delivery-contract",
prompt: "deliver the follow-up contract turn",
originChannel: "discord",
originTo: "channel:C1",
dispatcherText: "visible dispatcher fallback",
visibleText: "visible follow-up",
silentText: "NO_REPLY",
jsonSilentText: '{"action":"NO_REPLY"}',
} as const;

View File

@@ -0,0 +1,94 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { vi } from "vitest";
import { __testing as beforeToolCallTesting } from "../../../src/agents/pi-tools.before-tool-call.js";
import type {
CodexAppServerExtensionFactory,
CodexAppServerToolResultEvent,
} from "../../../src/plugins/codex-app-server-extension-types.js";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "../../../src/plugins/hook-runner-global.js";
import { createMockPluginRegistry } from "../../../src/plugins/hooks.test-helpers.js";
import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.js";
import {
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "../../../src/plugins/runtime.js";
export function textToolResult(
text: string,
details: Record<string, unknown> = {},
): AgentToolResult<unknown> {
return {
content: [{ type: "text", text }],
details,
};
}
export function mediaToolResult(
text: string,
mediaUrl: string,
audioAsVoice = false,
): AgentToolResult<unknown> {
return textToolResult(text, {
media: {
mediaUrl,
...(audioAsVoice ? { audioAsVoice } : {}),
},
});
}
export function installOpenClawOwnedToolHooks(params?: {
adjustedParams?: Record<string, unknown>;
blockReason?: string;
}) {
const beforeToolCall = vi.fn(async () => {
if (params?.blockReason) {
return {
block: true,
blockReason: params.blockReason,
};
}
return params?.adjustedParams ? { params: params.adjustedParams } : {};
});
const afterToolCall = vi.fn(async () => {});
initializeGlobalHookRunner(
createMockPluginRegistry([
{ hookName: "before_tool_call", handler: beforeToolCall },
{ hookName: "after_tool_call", handler: afterToolCall },
]),
);
return { beforeToolCall, afterToolCall };
}
/**
* Installs only the Codex app-server `tool_result` middleware fixture.
* Pair with `installOpenClawOwnedToolHooks()` when a test asserts before/after hook behavior.
*/
export function installCodexToolResultMiddleware(
handler: (event: CodexAppServerToolResultEvent) => AgentToolResult<unknown>,
) {
const middleware = vi.fn(async (event: CodexAppServerToolResultEvent) => ({
result: handler(event),
}));
const registry = createEmptyPluginRegistry();
const factory: CodexAppServerExtensionFactory = async (codex) => {
codex.on("tool_result", middleware);
};
registry.codexAppServerExtensionFactories.push({
pluginId: "runtime-contract",
pluginName: "Runtime Contract",
rawFactory: factory,
factory,
source: "test",
});
setActivePluginRegistry(registry);
return { middleware };
}
export function resetOpenClawOwnedToolHooks(): void {
resetGlobalHookRunner();
resetPluginRuntimeStateForTest();
beforeToolCallTesting.adjustedParamsByToolCallId.clear();
}

View File

@@ -0,0 +1,48 @@
import type { EmbeddedPiRunResult } from "../../../src/agents/pi-embedded-runner/types.js";
export const OUTCOME_FALLBACK_RUNTIME_CONTRACT = {
primaryProvider: "openai-codex",
primaryModel: "gpt-5.4",
fallbackProvider: "anthropic",
fallbackModel: "claude-haiku-3-5",
sessionId: "session-outcome-contract",
sessionKey: "agent:main:outcome-contract",
runId: "run-outcome-contract",
prompt: "finish the contract turn",
reasoningOnlyText: "I need to reason about this before answering.",
planningOnlyText: "Inspect state, then decide the next step.",
} as const;
export function createContractRunResult(
overrides: Partial<EmbeddedPiRunResult> = {},
): EmbeddedPiRunResult {
const { meta, ...rest } = overrides;
return {
payloads: [],
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
successfulCronAdds: 0,
...rest,
meta: {
durationMs: 1,
...meta,
},
};
}
export function createContractFallbackConfig() {
return {
agents: {
defaults: {
model: {
primary: `${OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider}/${OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel}`,
fallbacks: [
`${OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackProvider}/${OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackModel}`,
],
},
},
},
} as const;
}

View File

@@ -0,0 +1,48 @@
import type { OpenClawConfig } from "../../../src/config/types.openclaw.js";
import type { ProviderSystemPromptContributionContext } from "../../../src/plugins/types.js";
export const GPT5_CONTRACT_MODEL_ID = "gpt-5.4";
export const GPT5_PREFIXED_CONTRACT_MODEL_ID = "openai/gpt-5.4";
export const NON_GPT5_CONTRACT_MODEL_ID = "gpt-4.1";
export const OPENAI_CONTRACT_PROVIDER_ID = "openai";
export const OPENAI_CODEX_CONTRACT_PROVIDER_ID = "openai-codex";
export const CODEX_CONTRACT_PROVIDER_ID = "codex";
export const NON_OPENAI_CONTRACT_PROVIDER_ID = "openrouter";
export function openAiPluginPersonalityConfig(personality: "friendly" | "off"): OpenClawConfig {
return {
plugins: {
entries: {
openai: {
config: { personality },
},
},
},
} satisfies OpenClawConfig;
}
export function sharedGpt5PersonalityConfig(personality: "friendly" | "off"): OpenClawConfig {
return {
agents: {
defaults: {
promptOverlays: {
gpt5: { personality },
},
},
},
} satisfies OpenClawConfig;
}
export function codexPromptOverlayContext(params?: {
modelId?: string;
config?: OpenClawConfig;
}): ProviderSystemPromptContributionContext {
return {
provider: CODEX_CONTRACT_PROVIDER_ID,
modelId: params?.modelId ?? GPT5_CONTRACT_MODEL_ID,
promptMode: "full",
agentDir: "/tmp/openclaw-codex-prompt-contract-agent",
workspaceDir: "/tmp/openclaw-codex-prompt-contract-workspace",
...(params?.config ? { config: params.config } : {}),
};
}

View File

@@ -0,0 +1,92 @@
export function createParameterFreeTool(name = "ping") {
return {
name,
description: "Parameter-free test tool",
parameters: {},
};
}
export function createStrictCompatibleTool(name = "lookup") {
return {
name,
description: "Strict-compatible test tool",
parameters: {
type: "object",
properties: {
path: { type: "string" },
},
required: ["path"],
additionalProperties: false,
},
};
}
export function createPermissiveTool(name = "schedule") {
return {
name,
description: "Permissive test tool",
parameters: {
type: "object",
properties: {
action: { type: "string" },
cron: { type: "string" },
},
required: ["action"],
additionalProperties: true,
},
};
}
export function createNativeOpenAIResponsesModel() {
return {
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
};
}
export function createNativeOpenAICodexResponsesModel() {
return {
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
};
}
export function createProxyOpenAIResponsesModel() {
return {
id: "custom-gpt",
name: "Custom GPT",
api: "openai-responses",
provider: "openai",
baseUrl: "https://proxy.example.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
};
}
export function normalizedParameterFreeSchema() {
return {
type: "object",
properties: {},
required: [],
additionalProperties: false,
};
}

View File

@@ -0,0 +1,62 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
export const QUEUED_USER_MESSAGE_MARKER =
"[Queued user message that arrived while the previous turn was still active]";
export function textOrphanLeaf(text = "older active-turn message"): { content: string } {
return { content: text };
}
export function structuredOrphanLeaf(): { content: unknown[] } {
return {
content: [
{ type: "text", text: "please inspect this" },
{ type: "image_url", image_url: { url: "https://example.test/cat.png" } },
{ type: "input_audio", audio_url: "https://example.test/cat.wav" },
],
};
}
export function inlineDataUriOrphanLeaf(): { content: unknown[] } {
return {
content: [
{ type: "text", text: "please inspect this inline image" },
{ type: "image_url", image_url: { url: `data:image/png;base64,${"a".repeat(4096)}` } },
],
};
}
export function mediaOnlyHistoryMessage(): AgentMessage {
return {
role: "user",
content: [{ type: "image", data: "b".repeat(2048), mimeType: "image/png" }],
timestamp: 1,
} as AgentMessage;
}
export function structuredHistoryMessage(): AgentMessage {
return {
role: "user",
content: [
{ type: "text", text: "older structured context" },
{ type: "image", data: "c".repeat(64), mimeType: "image/png" },
],
timestamp: 1,
} as AgentMessage;
}
export function currentPromptHistoryMessage(prompt: string): AgentMessage {
return {
role: "user",
content: [{ type: "text", text: prompt }],
timestamp: 2,
} as AgentMessage;
}
export function assistantHistoryMessage(text = "ack"): AgentMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
timestamp: 2,
} as AgentMessage;
}

View File

@@ -0,0 +1,33 @@
export const OPENAI_GPT5_TRANSPORT_DEFAULTS = {
parallel_tool_calls: true,
text_verbosity: "low",
openaiWsWarmup: false,
} as const;
export const OPENAI_GPT5_TRANSPORT_DEFAULT_CASES = [
{
provider: "openai",
modelId: "gpt-5.4",
},
{
provider: "openai-codex",
modelId: "gpt-5.4",
},
] as const;
export const NON_OPENAI_GPT5_TRANSPORT_CASE = {
provider: "openrouter",
modelId: "gpt-5.4",
} as const;
export const GPT_PARALLEL_TOOL_CALLS_PAYLOAD_APIS = [
"openai-completions",
"openai-responses",
"openai-codex-responses",
"azure-openai-responses",
] as const;
export const UNRELATED_TOOL_CALLS_PAYLOAD_APIS = [
"anthropic-messages",
"google-generative-ai",
] as const;