mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
* fix(codex): scope heartbeat guidance to collaboration mode * fix heartbeat tool direct context * test prompt heartbeat collaboration snapshots * fix heartbeat changelog credit
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "./prompt-overlay.js";
|
|
import { codexProviderDiscovery } from "./provider-discovery.js";
|
|
import { buildCodexProvider, buildCodexProviderCatalog } from "./provider.js";
|
|
import { CodexAppServerClient } from "./src/app-server/client.js";
|
|
import {
|
|
getSharedCodexAppServerClient,
|
|
resetSharedCodexAppServerClientForTests,
|
|
} from "./src/app-server/shared-client.js";
|
|
|
|
afterEach(() => {
|
|
resetSharedCodexAppServerClientForTests();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function expectStaticFallbackCatalog(
|
|
result: Awaited<ReturnType<typeof buildCodexProviderCatalog>>,
|
|
) {
|
|
expect(result.provider.models.map((model) => model.id)).toEqual([
|
|
"gpt-5.5",
|
|
"gpt-5.4-mini",
|
|
"gpt-5.2",
|
|
]);
|
|
}
|
|
|
|
function createFakeCodexClient(): CodexAppServerClient {
|
|
return {
|
|
initialize: vi.fn(async () => undefined),
|
|
request: vi.fn(async () => ({ data: [] })),
|
|
addCloseHandler: vi.fn(() => () => undefined),
|
|
close: vi.fn(),
|
|
} as unknown as CodexAppServerClient;
|
|
}
|
|
|
|
describe("codex provider", () => {
|
|
it("maps Codex app-server models to a Codex provider catalog", async () => {
|
|
const listModels = vi.fn(async () => ({
|
|
models: [
|
|
{
|
|
id: "gpt-5.4",
|
|
model: "gpt-5.4",
|
|
displayName: "gpt-5.4",
|
|
hidden: false,
|
|
inputModalities: ["text", "image"],
|
|
supportedReasoningEfforts: ["low", "medium", "high", "xhigh"],
|
|
},
|
|
{
|
|
id: "hidden-model",
|
|
model: "hidden-model",
|
|
hidden: true,
|
|
inputModalities: ["text"],
|
|
supportedReasoningEfforts: [],
|
|
},
|
|
],
|
|
}));
|
|
|
|
const result = await buildCodexProviderCatalog({
|
|
env: {},
|
|
listModels,
|
|
pluginConfig: { discovery: { timeoutMs: 1234 } },
|
|
});
|
|
|
|
expect(listModels).toHaveBeenCalledWith(
|
|
expect.objectContaining({ limit: 100, timeoutMs: 1234, sharedClient: false }),
|
|
);
|
|
expect(result.provider).toMatchObject({
|
|
auth: "token",
|
|
api: "openai-codex-responses",
|
|
models: [
|
|
{
|
|
id: "gpt-5.4",
|
|
name: "gpt-5.4",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
compat: { supportsReasoningEffort: true },
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("keeps a static fallback catalog when discovery is disabled", async () => {
|
|
const listModels = vi.fn();
|
|
|
|
const result = await buildCodexProviderCatalog({
|
|
env: {},
|
|
listModels,
|
|
pluginConfig: { discovery: { enabled: false } },
|
|
});
|
|
|
|
expect(listModels).not.toHaveBeenCalled();
|
|
expectStaticFallbackCatalog(result);
|
|
});
|
|
|
|
it("uses live plugin config to re-enable discovery after startup disable", async () => {
|
|
const listModels = vi.fn(async () => ({
|
|
models: [
|
|
{
|
|
id: "gpt-5.4",
|
|
model: "gpt-5.4",
|
|
displayName: "gpt-5.4",
|
|
hidden: false,
|
|
inputModalities: ["text", "image"],
|
|
supportedReasoningEfforts: ["low", "medium", "high", "xhigh"],
|
|
},
|
|
],
|
|
}));
|
|
const provider = buildCodexProvider({
|
|
pluginConfig: { discovery: { enabled: false } },
|
|
listModels,
|
|
});
|
|
|
|
const result = await provider.catalog?.run({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
codex: {
|
|
config: {
|
|
discovery: {
|
|
enabled: true,
|
|
timeoutMs: 4321,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
env: {},
|
|
} as never);
|
|
|
|
expect(listModels).toHaveBeenCalledWith(
|
|
expect.objectContaining({ limit: 100, timeoutMs: 4321, sharedClient: false }),
|
|
);
|
|
expect(result).toMatchObject({
|
|
provider: {
|
|
models: [{ id: "gpt-5.4" }],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("pages through live discovery before building the provider catalog", async () => {
|
|
const listModels = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
models: [
|
|
{
|
|
id: "gpt-5.4",
|
|
model: "gpt-5.4",
|
|
hidden: false,
|
|
inputModalities: ["text", "image"],
|
|
supportedReasoningEfforts: ["medium"],
|
|
},
|
|
],
|
|
nextCursor: "page-2",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
models: [
|
|
{
|
|
id: "gpt-5.2",
|
|
model: "gpt-5.2",
|
|
hidden: false,
|
|
inputModalities: ["text"],
|
|
supportedReasoningEfforts: [],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await buildCodexProviderCatalog({
|
|
env: {},
|
|
listModels,
|
|
});
|
|
|
|
expect(listModels).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({ cursor: undefined, limit: 100, sharedClient: false }),
|
|
);
|
|
expect(listModels).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({ cursor: "page-2", limit: 100, sharedClient: false }),
|
|
);
|
|
expect(result.provider.models.map((model) => model.id)).toEqual(["gpt-5.4", "gpt-5.2"]);
|
|
});
|
|
|
|
it("reports discovery failures before using the fallback catalog", async () => {
|
|
const error = new Error("app-server down");
|
|
const onDiscoveryFailure = vi.fn();
|
|
const listModels = vi.fn(async () => {
|
|
throw error;
|
|
});
|
|
|
|
const result = await buildCodexProviderCatalog({
|
|
env: {},
|
|
listModels,
|
|
onDiscoveryFailure,
|
|
});
|
|
|
|
expect(onDiscoveryFailure).toHaveBeenCalledWith(error);
|
|
expectStaticFallbackCatalog(result);
|
|
});
|
|
|
|
it("keeps a static fallback catalog when live discovery is explicitly disabled by env", async () => {
|
|
const listModels = vi.fn();
|
|
|
|
const result = await buildCodexProviderCatalog({
|
|
env: { OPENCLAW_CODEX_DISCOVERY_LIVE: "0" },
|
|
listModels,
|
|
});
|
|
|
|
expect(listModels).not.toHaveBeenCalled();
|
|
expectStaticFallbackCatalog(result);
|
|
});
|
|
|
|
it("closes the transient app-server client after live discovery", async () => {
|
|
const client = createFakeCodexClient();
|
|
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(client);
|
|
|
|
await buildCodexProviderCatalog({
|
|
env: { OPENCLAW_CODEX_DISCOVERY_LIVE: "1" },
|
|
});
|
|
|
|
expect(client.close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not close an active shared app-server client during live discovery", async () => {
|
|
const activeClient = createFakeCodexClient();
|
|
const discoveryClient = createFakeCodexClient();
|
|
vi.spyOn(CodexAppServerClient, "start")
|
|
.mockReturnValueOnce(activeClient)
|
|
.mockReturnValueOnce(discoveryClient);
|
|
|
|
await getSharedCodexAppServerClient({ timeoutMs: 1000 });
|
|
await buildCodexProviderCatalog({
|
|
env: { OPENCLAW_CODEX_DISCOVERY_LIVE: "1" },
|
|
});
|
|
|
|
expect(activeClient.close).not.toHaveBeenCalled();
|
|
expect(discoveryClient.close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("resolves arbitrary Codex app-server model ids as text-only until discovered", () => {
|
|
const provider = buildCodexProvider();
|
|
|
|
const model = provider.resolveDynamicModel?.({
|
|
provider: "codex",
|
|
modelId: " custom-model ",
|
|
modelRegistry: { find: () => null },
|
|
} as never);
|
|
|
|
expect(model).toMatchObject({
|
|
id: "custom-model",
|
|
provider: "codex",
|
|
api: "openai-codex-responses",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
input: ["text"],
|
|
});
|
|
});
|
|
|
|
it("keeps fallback Codex app-server models image-capable", () => {
|
|
const provider = buildCodexProvider();
|
|
|
|
const model = provider.resolveDynamicModel?.({
|
|
provider: "codex",
|
|
modelId: "gpt-5.5",
|
|
modelRegistry: { find: () => null },
|
|
} as never);
|
|
|
|
expect(model).toMatchObject({
|
|
id: "gpt-5.5",
|
|
input: ["text", "image"],
|
|
});
|
|
});
|
|
|
|
it("treats o4 ids as reasoning-capable Codex models", () => {
|
|
const provider = buildCodexProvider();
|
|
|
|
const model = provider.resolveDynamicModel?.({
|
|
provider: "codex",
|
|
modelId: "o4-mini",
|
|
modelRegistry: { find: () => null },
|
|
} as never);
|
|
|
|
expect(model).toMatchObject({
|
|
id: "o4-mini",
|
|
reasoning: true,
|
|
compat: { supportsReasoningEffort: true },
|
|
});
|
|
expect(
|
|
provider
|
|
.resolveThinkingProfile?.({ provider: "codex", modelId: "o4-mini" } as never)
|
|
?.levels.some((level) => level.id === "xhigh"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("declares synthetic auth because the harness owns Codex credentials", () => {
|
|
const provider = buildCodexProvider();
|
|
|
|
expect(provider.resolveSyntheticAuth?.({ provider: "codex" })).toEqual({
|
|
apiKey: "codex-app-server",
|
|
source: "codex-app-server",
|
|
mode: "token",
|
|
});
|
|
});
|
|
|
|
it("exposes a setup auth choice for installing Codex as an external provider", async () => {
|
|
const provider = buildCodexProvider();
|
|
|
|
expect(provider.auth[0]).toMatchObject({
|
|
id: "app-server",
|
|
kind: "custom",
|
|
wizard: {
|
|
choiceId: "codex",
|
|
choiceLabel: "Codex app-server",
|
|
onboardingScopes: ["text-inference"],
|
|
},
|
|
});
|
|
await expect(provider.auth[0].run({} as never)).resolves.toMatchObject({
|
|
profiles: [],
|
|
defaultModel: "codex/gpt-5.5",
|
|
});
|
|
});
|
|
|
|
it("exposes a lightweight provider-discovery entry for model list/status", async () => {
|
|
expect(codexProviderDiscovery.id).toBe("codex");
|
|
expect(codexProviderDiscovery.resolveSyntheticAuth?.({ provider: "codex" })).toEqual({
|
|
apiKey: "codex-app-server",
|
|
source: "codex-app-server",
|
|
mode: "token",
|
|
});
|
|
|
|
const result = await codexProviderDiscovery.staticCatalog?.run({
|
|
config: {},
|
|
env: {},
|
|
agentDir: "/tmp/openclaw-agent",
|
|
} as never);
|
|
|
|
expect(
|
|
result && "provider" in result ? result.provider.models.map((model) => model.id) : [],
|
|
).toEqual(["gpt-5.5", "gpt-5.4-mini", "gpt-5.2"]);
|
|
});
|
|
|
|
it("adds the GPT-5 prompt overlay to Codex provider runs", () => {
|
|
const provider = buildCodexProvider();
|
|
|
|
expect(
|
|
provider.resolveSystemPromptContribution?.({
|
|
provider: "codex",
|
|
modelId: "gpt-5.4",
|
|
} as never),
|
|
).toEqual({
|
|
stablePrefix: CODEX_GPT5_BEHAVIOR_CONTRACT,
|
|
sectionOverrides: {
|
|
interaction_style: expect.stringContaining("This is a live chat, not a memo."),
|
|
},
|
|
});
|
|
expect(
|
|
provider.resolveSystemPromptContribution?.({
|
|
provider: "codex",
|
|
modelId: "gpt-5.4",
|
|
} as never)?.sectionOverrides?.interaction_style,
|
|
).not.toContain("The purpose of heartbeats is to make you feel magical and proactive.");
|
|
});
|
|
|
|
it("does not add the GPT-5 prompt overlay to non-GPT-5 Codex provider runs", () => {
|
|
const provider = buildCodexProvider();
|
|
|
|
expect(
|
|
provider.resolveSystemPromptContribution?.({
|
|
provider: "codex",
|
|
modelId: "o4-mini",
|
|
} as never),
|
|
).toBeUndefined();
|
|
});
|
|
});
|