mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-01 00:36:36 +00:00
* refactor: extract agent core package Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts. * refactor: extract shared llm runtime Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout. * refactor: remove pi runtime internals Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code. * refactor: tighten agent session runtime Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts. * refactor: remove static model and pi auth paths Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities. * refactor: remove legacy provider compat paths * docs: remove agent parity notes * fix: skip provider wildcard metadata parsing * refactor: share session extension sdk loading * refactor: inline acpx proxy error formatter * refactor: fold edit recovery into edit tool * fix: accept extension batch separator * test: align startup provider plugin expectations * fix: restore provider-scoped release discovery * test: align static asset packaging expectations * fix: run static provider catalogs during scoped discovery * fix: add provider entry catalogs for scoped live discovery * fix: load lightweight provider catalog entries * fix: refresh provider-scoped plugin metadata * fix: keep provider catalog entries on release live path * fix: keep static manifest models in release live checks * fix: harden release model discovery * fix: reduce OpenAI live cache probe reasoning * fix: disable OpenAI cache probe reasoning * ci: extend OpenAI gateway live timeout * fix: extend live gateway model budget * fix: stabilize release validation regressions * fix: honor provider aliases in model rows * fix: stabilize release validation lanes * fix: stabilize release memory qa * ci: stabilize release validation lanes * ci: prefer ipv4 for live docker node calls * fix: restore shared tool-call stream wrapper * ci: remove legacy pi test shard alias * fix: clean up embedded agent test drift * fix: stabilize runtime alias status * fix: clean up embedded agent ci drift * fix: restore release ci invariants * fix: clean up post-rebase runtime drift * fix: restore release ci checks * fix: restore release ci after rebase * fix: remove stale pi runtime path * test: align compaction runtime expectations * test: update plugin prerelease expectations * fix: handle claude live tool approvals * fix: stabilize release validation gates * fix: finish agent runtime import * test: finish post-rebase agent runtime mocks * fix: keep codex compaction native * fix: stabilize codex app-server hook tests * test: isolate codex diagnostic active run * test: remove codex diagnostic completion race # Conflicts: # extensions/codex/src/app-server/run-attempt.test.ts * ci: fix full release manifest performance run id * refactor: narrow llm plugin sdk boundary * chore: drop generated google boundary stamps * fix: repair rebase fallout * fix: clean up rebased runtime references * fix: decode codex jwt payloads as base64url * fix: preserve shipped pi runtime alias * fix: add scoped sdk virtual modules * fix: decode llm codex oauth jwt as base64url * fix: avoid stale vertex adc negative cache * fix: harden tool arg decoding and codeql path * fix: keep vertex adc negative checks live * refactor: consolidate codex jwt and edit helpers * fix: await codex oauth node runtime imports * fix: preserve sdk tool and notice contracts * fix: preserve shipped compat config boundaries * fix: align codex oauth callback host * fix: terminate agent-core loop streams on failure * fix: keep codex oauth callback alive during fallback * ci: include session tools in critical codeql scans * fix: keep Cloudflare Anthropic provider auth header * docs: redirect legacy pi runtime pages * fix: honor bundled web provider compat discovery * fix: protect session output spill files * fix: keep legacy agent dir env blocked * fix: contain auto-discovered skill symlinks * fix: harden agent core sdk proxy surfaces * fix: restore approval reaction sdk compat * fix: keep live docker runs bounded * fix: keep codex oauth redirect host aligned * fix: resolve post-rebase agent runtime drift * fix: redact anthropic oauth parse failures * fix: preserve responses strict tool shaping * fix: repair agent runtime rebase cleanup * docs: redirect retired parity pages * fix: bound auto-discovered resources to roots * fix: repair post-rebase agent test drift * fix: preserve bundled provider allowlist migration * fix: preserve manifest-owned provider aliases * fix: declare photon image dependency * fix: keep provider headers out of proxy body * fix: preserve shipped env aliases * fix: refresh control ui i18n generated state * fix: quote read fallback paths * fix: preview edits through configured backend * test: satisfy core test typecheck * fix: preserve ZAI usage auth fallback * test: repair codex diagnostic test * fix: repair agent runtime rebase drift * test: finish embedded runner import rename * fix: repair agent runtime rebase integrations * test: align compaction oauth fallback expectations * fix: allow sdk-auth session models * fix: update doctor tool schema import * fix: preserve bedrock plugin region * fix: stream harmony-like prose immediately * ci: include session runtime in codeql shards * fix: repair latest rebase integrations * fix: honor explicit codex websocket transport * fix: keep openai-compatible credentials provider-scoped * fix: refresh sdk api baseline after rebase * fix: route cli runtime aliases through openclaw harness * test: rename stale harness mock expectation * test: rename embedded agent overflow calls * test: clean embedded auth test wording * test: use openclaw stream types in deepinfra cache test * fix: refresh sdk api baseline on latest main * fix: honor bundled discovery compat allowlists * fix: refresh sdk api baseline after latest rebase * fix: remove stale rebase imports * test: rename stale model catalog mock * test: mock renamed doctor runtime modules * fix: map canonical kimi env auth * fix: use internal model registry in bench script * fix: migrate deepinfra provider catalog entry * fix: enforce builtin tool suppression * fix: route compaction auth and proxy payloads safely * refactor: prune unused llm registry leftovers * test: update codex hooks session import * test: fix model picker ci coverage * test: align model picker auth mock types
493 lines
14 KiB
TypeScript
493 lines
14 KiB
TypeScript
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
|
import { streamSimple, type Api, type Context, type Model } from "openclaw/plugin-sdk/llm";
|
|
import { describe, expect, it } from "vitest";
|
|
import { applyXaiRuntimeModelCompat } from "./runtime-model-compat.js";
|
|
import {
|
|
createXaiFastModeWrapper,
|
|
createXaiToolPayloadCompatibilityWrapper,
|
|
wrapXaiProviderStream,
|
|
} from "./stream.js";
|
|
import {
|
|
createXaiPayloadCaptureStream,
|
|
expectXaiFastToolStreamShaping,
|
|
runXaiGrok4ResponseStream,
|
|
} from "./test-helpers.js";
|
|
type XaiStreamApi = Extract<Api, "openai-completions" | "openai-responses">;
|
|
type StreamEvent = Record<string, unknown> & { type?: string };
|
|
|
|
async function collectEvents(stream: ReturnType<StreamFn>): Promise<StreamEvent[]> {
|
|
const events: StreamEvent[] = [];
|
|
for await (const event of stream as AsyncIterable<StreamEvent>) {
|
|
events.push(event);
|
|
}
|
|
return events;
|
|
}
|
|
|
|
function buildEventStreamFn(events: unknown[]): StreamFn {
|
|
return (() =>
|
|
({
|
|
result: async () => {
|
|
const done = events.find((event) => {
|
|
const record = event && typeof event === "object" ? (event as { type?: unknown }) : {};
|
|
return record.type === "done";
|
|
}) as { message?: unknown } | undefined;
|
|
return (done?.message ?? { role: "assistant", content: [] }) as never;
|
|
},
|
|
async *[Symbol.asyncIterator]() {
|
|
for (const event of events) {
|
|
yield event as never;
|
|
}
|
|
},
|
|
}) as unknown as ReturnType<StreamFn>) as StreamFn;
|
|
}
|
|
|
|
function captureWrappedModelId(params: {
|
|
modelId: string;
|
|
fastMode: boolean;
|
|
api?: XaiStreamApi;
|
|
}): string {
|
|
let capturedModelId = "";
|
|
const baseStreamFn: StreamFn = (model) => {
|
|
capturedModelId = model.id;
|
|
return {} as ReturnType<StreamFn>;
|
|
};
|
|
|
|
const wrapped = createXaiFastModeWrapper(baseStreamFn, params.fastMode);
|
|
void wrapped(
|
|
{
|
|
api: params.api ?? "openai-responses",
|
|
provider: "xai",
|
|
id: params.modelId,
|
|
} as Model<Extract<Api, "openai-completions" | "openai-responses">>,
|
|
{ messages: [] } as Context,
|
|
{},
|
|
);
|
|
|
|
return capturedModelId;
|
|
}
|
|
|
|
function runXaiToolPayloadWrapper(params: {
|
|
payload: Record<string, unknown>;
|
|
api?: XaiStreamApi;
|
|
modelId?: string;
|
|
input?: string[];
|
|
}) {
|
|
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
|
options?.onPayload?.(params.payload, {} as Model<XaiStreamApi>);
|
|
return {} as ReturnType<StreamFn>;
|
|
};
|
|
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
|
|
const api = params.api ?? "openai-responses";
|
|
|
|
void wrapped(
|
|
{
|
|
api,
|
|
provider: "xai",
|
|
id:
|
|
params.modelId ??
|
|
(api === "openai-completions" ? "grok-4-1-fast-reasoning" : "grok-4-fast"),
|
|
reasoning: params.modelId ? !params.modelId.includes("non-reasoning") : true,
|
|
...(params.input ? { input: params.input } : {}),
|
|
} as Model<XaiStreamApi>,
|
|
{ messages: [] } as Context,
|
|
{},
|
|
);
|
|
}
|
|
|
|
async function captureXaiResponsesPayloadWithThinking(): Promise<Record<string, unknown>> {
|
|
const model = applyXaiRuntimeModelCompat({
|
|
api: "openai-responses",
|
|
provider: "xai",
|
|
id: "grok-4.3",
|
|
baseUrl: "https://api.x.ai/v1",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 1_000_000,
|
|
maxTokens: 64_000,
|
|
} as Model<"openai-responses">);
|
|
|
|
const payloadPromise = new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
const timeout = setTimeout(
|
|
() => reject(new Error("provider payload callback was not invoked")),
|
|
1_000,
|
|
);
|
|
const stream = streamSimple(
|
|
model,
|
|
{ messages: [{ role: "user", content: "hello", timestamp: 0 }] },
|
|
{
|
|
apiKey: "test-api-key",
|
|
cacheRetention: "none",
|
|
reasoning: "low",
|
|
onPayload: (payload) => {
|
|
clearTimeout(timeout);
|
|
resolve(structuredClone(payload as Record<string, unknown>));
|
|
throw new Error("stop after payload capture");
|
|
},
|
|
},
|
|
);
|
|
void stream.result();
|
|
});
|
|
|
|
return await payloadPromise;
|
|
}
|
|
|
|
describe("xai stream wrappers", () => {
|
|
it("rewrites supported Grok models to fast variants when fast mode is enabled", () => {
|
|
expect(captureWrappedModelId({ modelId: "grok-3", fastMode: true })).toBe("grok-3-fast");
|
|
expect(
|
|
captureWrappedModelId({
|
|
modelId: "grok-3",
|
|
fastMode: true,
|
|
api: "openai-completions",
|
|
}),
|
|
).toBe("grok-3-fast");
|
|
expect(captureWrappedModelId({ modelId: "grok-4", fastMode: true })).toBe("grok-4-fast");
|
|
expect(
|
|
captureWrappedModelId({
|
|
modelId: "grok-3",
|
|
fastMode: true,
|
|
api: "openai-responses",
|
|
}),
|
|
).toBe("grok-3-fast");
|
|
});
|
|
|
|
it("leaves unsupported or disabled models unchanged", () => {
|
|
expect(captureWrappedModelId({ modelId: "grok-3-fast", fastMode: true })).toBe("grok-3-fast");
|
|
expect(captureWrappedModelId({ modelId: "grok-3", fastMode: false })).toBe("grok-3");
|
|
});
|
|
|
|
it("composes the xai provider stream chain from extra params", () => {
|
|
const capture = createXaiPayloadCaptureStream();
|
|
|
|
const wrapped = wrapXaiProviderStream({
|
|
streamFn: capture.streamFn,
|
|
extraParams: { fastMode: true },
|
|
} as never);
|
|
|
|
runXaiGrok4ResponseStream(wrapped);
|
|
expectXaiFastToolStreamShaping(capture);
|
|
});
|
|
|
|
it("promotes standalone Grok-style tool text to a structured tool call", async () => {
|
|
const rawToolText = '[tool:read] {"path":"/app/skills/meme-maker/SKILL.md"}';
|
|
const baseStream = buildEventStreamFn([
|
|
{ type: "start", partial: { content: [] } },
|
|
{ type: "text_start", contentIndex: 0, partial: { content: [{ type: "text", text: "" }] } },
|
|
{ type: "text_delta", contentIndex: 0, delta: rawToolText },
|
|
{ type: "text_end", contentIndex: 0, content: rawToolText },
|
|
{
|
|
type: "done",
|
|
reason: "stop",
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: rawToolText }],
|
|
stopReason: "stop",
|
|
},
|
|
},
|
|
]);
|
|
const wrapped = wrapXaiProviderStream({
|
|
streamFn: baseStream,
|
|
extraParams: { tool_stream: false },
|
|
} as never);
|
|
|
|
const events = await collectEvents(
|
|
wrapped!(
|
|
{
|
|
api: "openai-responses",
|
|
provider: "xai",
|
|
id: "grok-4.3",
|
|
} as Model<"openai-responses">,
|
|
{
|
|
messages: [],
|
|
tools: [{ name: "read", description: "Read", parameters: { type: "object" } }],
|
|
} as unknown as Context,
|
|
{},
|
|
),
|
|
);
|
|
|
|
expect(events.map((event) => event.type)).toEqual([
|
|
"start",
|
|
"toolcall_start",
|
|
"toolcall_delta",
|
|
"done",
|
|
]);
|
|
const done = events.find((event) => event.type === "done") as {
|
|
message?: { content?: Array<Record<string, unknown>>; stopReason?: string };
|
|
reason?: string;
|
|
};
|
|
expect(done.reason).toBe("toolUse");
|
|
expect(done.message?.stopReason).toBe("toolUse");
|
|
expect(done.message?.content?.[0]).toMatchObject({
|
|
type: "toolCall",
|
|
name: "read",
|
|
arguments: { path: "/app/skills/meme-maker/SKILL.md" },
|
|
});
|
|
});
|
|
|
|
it("strips unsupported strict and reasoning controls from tool payloads", () => {
|
|
const payload = {
|
|
reasoning: "high",
|
|
reasoningEffort: "high",
|
|
reasoning_effort: "high",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "write",
|
|
parameters: { type: "object", properties: {} },
|
|
strict: true,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
runXaiToolPayloadWrapper({
|
|
payload,
|
|
api: "openai-completions",
|
|
modelId: "grok-4-fast-non-reasoning",
|
|
});
|
|
|
|
expect(payload).not.toHaveProperty("reasoning");
|
|
expect(payload).not.toHaveProperty("reasoningEffort");
|
|
expect(payload).not.toHaveProperty("reasoning_effort");
|
|
expect(payload.tools[0]?.function).not.toHaveProperty("strict");
|
|
});
|
|
|
|
it("strips unsupported reasoning controls from non-reasoning xai payloads", () => {
|
|
const payload: Record<string, unknown> = {
|
|
reasoning: { effort: "high" },
|
|
reasoningEffort: "high",
|
|
reasoning_effort: "high",
|
|
};
|
|
runXaiToolPayloadWrapper({ payload, modelId: "grok-4-fast-non-reasoning" });
|
|
|
|
expect(payload).not.toHaveProperty("reasoning");
|
|
expect(payload).not.toHaveProperty("reasoningEffort");
|
|
expect(payload).not.toHaveProperty("reasoning_effort");
|
|
});
|
|
|
|
it("passes reasoning controls through for reasoning-capable xai payloads", () => {
|
|
const payload: Record<string, unknown> = {
|
|
reasoning: { effort: "high" },
|
|
reasoningEffort: "high",
|
|
reasoning_effort: "high",
|
|
};
|
|
runXaiToolPayloadWrapper({ payload, modelId: "grok-4.3" });
|
|
|
|
expect(payload.reasoning).toEqual({ effort: "high" });
|
|
expect(payload.reasoningEffort).toBe("high");
|
|
expect(payload.reasoning_effort).toBe("high");
|
|
});
|
|
|
|
it("strips reasoning controls when compat disables reasoning effort", () => {
|
|
const payload: Record<string, unknown> = {
|
|
reasoning: { effort: "high" },
|
|
reasoningEffort: "high",
|
|
reasoning_effort: "high",
|
|
};
|
|
const baseStreamFn: StreamFn = (model, _context, options) => {
|
|
options?.onPayload?.(payload, model);
|
|
return {} as ReturnType<StreamFn>;
|
|
};
|
|
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
|
|
|
|
void wrapped(
|
|
{
|
|
api: "openai-responses",
|
|
provider: "xai",
|
|
id: "grok-4.20-beta-latest-reasoning",
|
|
reasoning: true,
|
|
compat: { supportsReasoningEffort: false },
|
|
} as unknown as Model<"openai-responses">,
|
|
{ messages: [] } as Context,
|
|
{},
|
|
);
|
|
|
|
expect(payload).not.toHaveProperty("reasoning");
|
|
expect(payload).not.toHaveProperty("reasoningEffort");
|
|
expect(payload).not.toHaveProperty("reasoning_effort");
|
|
});
|
|
|
|
it("keeps native xAI Responses thinking efforts before the shared runtime dispatches payloads", async () => {
|
|
const payload = await captureXaiResponsesPayloadWithThinking();
|
|
|
|
expect(payload.reasoning).toEqual({ effort: "low", summary: "auto" });
|
|
expect(payload.include).toEqual(["reasoning.encrypted_content"]);
|
|
});
|
|
|
|
it("moves image-bearing tool results out of function_call_output payloads", () => {
|
|
const payload: Record<string, unknown> = {
|
|
input: [
|
|
{
|
|
type: "function_call_output",
|
|
call_id: "call_1",
|
|
output: [
|
|
{ type: "input_text", text: "Read image" },
|
|
{
|
|
type: "input_image",
|
|
detail: "auto",
|
|
image_url: "data:image/png;base64,QUJDRA==",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
runXaiToolPayloadWrapper({ payload, input: ["text", "image"] });
|
|
|
|
expect(payload.input).toEqual([
|
|
{
|
|
type: "function_call_output",
|
|
call_id: "call_1",
|
|
output: "Read image",
|
|
},
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [
|
|
{ type: "input_text", text: "Attached image(s) from tool result:" },
|
|
{
|
|
type: "input_image",
|
|
detail: "auto",
|
|
image_url: "data:image/png;base64,QUJDRA==",
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("replays source-based input_image parts from tool results", () => {
|
|
const payload: Record<string, unknown> = {
|
|
input: [
|
|
{
|
|
type: "function_call_output",
|
|
call_id: "call_1",
|
|
output: [
|
|
{ type: "input_text", text: "Read image" },
|
|
{
|
|
type: "input_image",
|
|
source: {
|
|
type: "base64",
|
|
media_type: "image/png",
|
|
data: "QUJDRA==",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
runXaiToolPayloadWrapper({ payload, input: ["text", "image"] });
|
|
|
|
expect(payload.input).toEqual([
|
|
{
|
|
type: "function_call_output",
|
|
call_id: "call_1",
|
|
output: "Read image",
|
|
},
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [
|
|
{ type: "input_text", text: "Attached image(s) from tool result:" },
|
|
{
|
|
type: "input_image",
|
|
source: {
|
|
type: "base64",
|
|
media_type: "image/png",
|
|
data: "QUJDRA==",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("keeps multiple tool outputs contiguous before replaying collected images", () => {
|
|
const payload: Record<string, unknown> = {
|
|
input: [
|
|
{
|
|
type: "function_call_output",
|
|
call_id: "call_1",
|
|
output: [
|
|
{ type: "input_text", text: "first" },
|
|
{
|
|
type: "input_image",
|
|
detail: "auto",
|
|
image_url: "data:image/png;base64,QUFBQQ==",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "function_call_output",
|
|
call_id: "call_2",
|
|
output: [
|
|
{ type: "input_text", text: "second" },
|
|
{
|
|
type: "input_image",
|
|
detail: "auto",
|
|
image_url: "data:image/png;base64,QkJCQg==",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
runXaiToolPayloadWrapper({ payload, input: ["text", "image"] });
|
|
|
|
expect(payload.input).toEqual([
|
|
{
|
|
type: "function_call_output",
|
|
call_id: "call_1",
|
|
output: "first",
|
|
},
|
|
{
|
|
type: "function_call_output",
|
|
call_id: "call_2",
|
|
output: "second",
|
|
},
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [
|
|
{ type: "input_text", text: "Attached image(s) from tool result:" },
|
|
{
|
|
type: "input_image",
|
|
detail: "auto",
|
|
image_url: "data:image/png;base64,QUFBQQ==",
|
|
},
|
|
{
|
|
type: "input_image",
|
|
detail: "auto",
|
|
image_url: "data:image/png;base64,QkJCQg==",
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("drops image blocks and uses fallback text for models without image input", () => {
|
|
const payload: Record<string, unknown> = {
|
|
input: [
|
|
{
|
|
type: "function_call_output",
|
|
call_id: "call_1",
|
|
output: [
|
|
{
|
|
type: "input_image",
|
|
detail: "auto",
|
|
image_url: "data:image/png;base64,QUJDRA==",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
runXaiToolPayloadWrapper({ payload, input: ["text"] });
|
|
|
|
expect(payload.input).toEqual([
|
|
{
|
|
type: "function_call_output",
|
|
call_id: "call_1",
|
|
output: "(see attached image)",
|
|
},
|
|
]);
|
|
});
|
|
});
|