mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:00:42 +00:00
[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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
45
extensions/codex/prompt-overlay-runtime-contract.test.ts
Normal file
45
extensions/codex/prompt-overlay-runtime-contract.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
406
src/agents/auth-profile-runtime-contract.test.ts
Normal file
406
src/agents/auth-profile-runtime-contract.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
32
src/agents/openai-tool-schema.test.ts
Normal file
32
src/agents/openai-tool-schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
390
src/agents/openclaw-owned-tool-runtime-contract.test.ts
Normal file
390
src/agents/openclaw-owned-tool-runtime-contract.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
187
src/agents/outcome-fallback-runtime-contract.test.ts
Normal file
187
src/agents/outcome-fallback-runtime-contract.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -282,6 +282,8 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
|
||||
api: "responses",
|
||||
}),
|
||||
"/tmp/workspace",
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
78
src/agents/prompt-overlay-runtime-contract.test.ts
Normal file
78
src/agents/prompt-overlay-runtime-contract.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
53
src/agents/runtime-plan/auth.ts
Normal file
53
src/agents/runtime-plan/auth.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
105
src/agents/runtime-plan/build.test.ts
Normal file
105
src/agents/runtime-plan/build.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
203
src/agents/runtime-plan/build.ts
Normal file
203
src/agents/runtime-plan/build.ts
Normal 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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
18
src/agents/runtime-plan/index.ts
Normal file
18
src/agents/runtime-plan/index.ts
Normal 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";
|
||||
204
src/agents/runtime-plan/types.ts
Normal file
204
src/agents/runtime-plan/types.ts
Normal 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;
|
||||
};
|
||||
107
src/agents/schema-normalization-runtime-contract.test.ts
Normal file
107
src/agents/schema-normalization-runtime-contract.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
239
src/agents/transport-params-runtime-contract.test.ts
Normal file
239
src/agents/transport-params-runtime-contract.test.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
73
src/plugin-sdk/schema-normalization-runtime-contract.test.ts
Normal file
73
src/plugin-sdk/schema-normalization-runtime-contract.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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?: {
|
||||
|
||||
60
test/helpers/agents/auth-profile-runtime-contract.ts
Normal file
60
test/helpers/agents/auth-profile-runtime-contract.ts
Normal 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;
|
||||
}
|
||||
12
test/helpers/agents/delivery-no-reply-runtime-contract.ts
Normal file
12
test/helpers/agents/delivery-no-reply-runtime-contract.ts
Normal 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;
|
||||
94
test/helpers/agents/openclaw-owned-tool-runtime-contract.ts
Normal file
94
test/helpers/agents/openclaw-owned-tool-runtime-contract.ts
Normal 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();
|
||||
}
|
||||
48
test/helpers/agents/outcome-fallback-runtime-contract.ts
Normal file
48
test/helpers/agents/outcome-fallback-runtime-contract.ts
Normal 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;
|
||||
}
|
||||
48
test/helpers/agents/prompt-overlay-runtime-contract.ts
Normal file
48
test/helpers/agents/prompt-overlay-runtime-contract.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
92
test/helpers/agents/schema-normalization-runtime-contract.ts
Normal file
92
test/helpers/agents/schema-normalization-runtime-contract.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
62
test/helpers/agents/transcript-repair-runtime-contract.ts
Normal file
62
test/helpers/agents/transcript-repair-runtime-contract.ts
Normal 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;
|
||||
}
|
||||
33
test/helpers/agents/transport-params-runtime-contract.ts
Normal file
33
test/helpers/agents/transport-params-runtime-contract.ts
Normal 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;
|
||||
Reference in New Issue
Block a user