Files
moltbot/extensions/github-copilot/stream.test.ts
Peter Steinberger 0b8ee4616d fix(github-copilot): support Gemini image understanding
Fixes Copilot image understanding by exchanging OAuth tokens for Copilot API tokens, routing Copilot Gemini image requests through Chat Completions, and sending the prompt in user content with Copilot vision headers.

Real behavior proof:
- Old Responses route with real Copilot key reproduced `400 model gemini-3.1-pro-preview does not support Responses API`.
- Fixed route with the same real Copilot key returned `Cat`.
- Final CLI live smoke returned `ok: true` and `text: Cat` for `github-copilot/gemini-3.1-pro-preview`.

Verification:
- pnpm test src/media-understanding/image.test.ts extensions/github-copilot/models.test.ts extensions/github-copilot/stream.test.ts src/agents/pi-hooks/compaction-safeguard.test.ts -- --reporter=verbose
- pnpm check:changed via Blacksmith Testbox tbx_01krgt56pqmft8txekt017wke6, Actions run https://github.com/openclaw/openclaw/actions/runs/25803926150, exit 0.

Refs #80393, #80442.

Co-authored-by: Yang Haoyu <150496764+afunnyhy@users.noreply.github.com>
2026-05-13 15:20:27 +01:00

274 lines
8.2 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { buildCopilotDynamicHeaders } from "./stream.js";
import {
wrapCopilotAnthropicStream,
wrapCopilotOpenAICompletionsStream,
wrapCopilotOpenAIResponsesStream,
wrapCopilotProviderStream,
} from "./stream.js";
function requireStreamFn(streamFn: ReturnType<typeof wrapCopilotProviderStream>) {
expect(streamFn).toBeTypeOf("function");
if (!streamFn) {
throw new Error("expected stream fn");
}
return streamFn;
}
function requireFirstStreamOptions(mock: ReturnType<typeof vi.fn>, label: string) {
const [call] = mock.mock.calls;
if (!call) {
throw new Error(`expected ${label}`);
}
const options = call[2];
if (!options || typeof options !== "object") {
throw new Error(`expected ${label} options`);
}
return options as { headers?: Record<string, unknown>; onPayload?: unknown };
}
describe("wrapCopilotAnthropicStream", () => {
it("adds Copilot headers and Anthropic cache markers for Claude payloads", () => {
const payloads: Array<{
messages: Array<Record<string, unknown>>;
}> = [];
const baseStreamFn = vi.fn((model, _context, options) => {
const payload = {
messages: [
{ role: "system", content: "system prompt" },
{
role: "assistant",
content: [{ type: "thinking", text: "draft", cache_control: { type: "ephemeral" } }],
},
],
};
options?.onPayload?.(payload, model);
payloads.push(payload);
return {
async *[Symbol.asyncIterator]() {},
} as never;
});
const wrapped = requireStreamFn(wrapCopilotAnthropicStream(baseStreamFn));
const messages = [
{
role: "user",
content: [
{ type: "text", text: "look" },
{ type: "image", image: "data:image/png;base64,abc" },
],
},
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
const context = { messages };
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
messages,
hasImages: true,
});
void wrapped(
{
provider: "github-copilot",
api: "anthropic-messages",
id: "claude-sonnet-4.6",
} as never,
context as never,
{
headers: { "X-Test": "1" },
},
);
expect(baseStreamFn).toHaveBeenCalledOnce();
const options = requireFirstStreamOptions(baseStreamFn, "Copilot Anthropic stream");
if (!options?.onPayload) {
throw new Error("expected Copilot Anthropic stream options");
}
expect(options).toEqual({
headers: {
...expectedCopilotHeaders,
"X-Test": "1",
},
onPayload: options.onPayload,
});
expect(payloads[0]?.messages).toEqual([
{
role: "system",
content: [{ type: "text", text: "system prompt", cache_control: { type: "ephemeral" } }],
},
{
role: "assistant",
content: [{ type: "thinking", text: "draft" }],
},
]);
});
it("leaves non-Anthropic Copilot models untouched", () => {
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);
const wrapped = requireStreamFn(wrapCopilotAnthropicStream(baseStreamFn));
const model = {
provider: "github-copilot",
api: "openai-responses",
id: "gpt-4.1",
} as never;
const context = { messages: [{ role: "user", content: "hi" }] } as never;
const options = { headers: { Existing: "1" } };
void wrapped(model, context, options as never);
expect(baseStreamFn.mock.calls).toEqual([[model, context, options]]);
});
it("adds Copilot headers, preserves reasoning IDs, and rewrites message IDs before payload send", () => {
const reasoningId = Buffer.from(`reasoning-${"x".repeat(24)}`).toString("base64");
const messageId = Buffer.from(`message-${"y".repeat(24)}`).toString("base64");
const payloads: Array<{ input: Array<Record<string, unknown>> }> = [];
const baseStreamFn = vi.fn((_model, _context, options) => {
const payload = {
input: [
{ id: reasoningId, type: "reasoning" },
{ id: messageId, type: "message" },
],
};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {
async *[Symbol.asyncIterator]() {},
} as never;
});
const wrapped = requireStreamFn(wrapCopilotOpenAIResponsesStream(baseStreamFn));
const messages = [
{
role: "toolResult",
content: [
{ type: "text", text: "look" },
{ type: "image", image: "data:image/png;base64,abc" },
],
},
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
messages,
hasImages: true,
});
void wrapped(
{
provider: "github-copilot",
api: "openai-responses",
id: "gpt-5.4",
} as never,
{ messages } as never,
{ headers: { "X-Test": "1" } },
);
expect(baseStreamFn).toHaveBeenCalledOnce();
const options = requireFirstStreamOptions(baseStreamFn, "Copilot Responses stream");
if (!options?.onPayload) {
throw new Error("expected Copilot Responses stream options");
}
expect(options).toEqual({
headers: {
...expectedCopilotHeaders,
"X-Test": "1",
},
onPayload: options.onPayload,
});
expect(payloads[0]?.input[0]?.id).toBe(reasoningId);
expect(payloads[0]?.input[1]?.id).toMatch(/^msg_[a-f0-9]{16}$/);
});
it("rewrites Copilot Responses IDs returned by an existing payload hook", async () => {
const connectionBoundId = Buffer.from(`message-${"y".repeat(24)}`).toString("base64");
let returnedPayload: unknown;
const baseStreamFn = vi.fn(async (_model, _context, options) => {
returnedPayload = await options?.onPayload?.({ input: [] }, _model);
return {
async *[Symbol.asyncIterator]() {},
} as never;
});
const wrapped = requireStreamFn(wrapCopilotOpenAIResponsesStream(baseStreamFn));
await wrapped(
{
provider: "github-copilot",
api: "openai-responses",
id: "gpt-5.4",
} as never,
{ messages: [{ role: "user", content: "hi" }] } as never,
{
onPayload: () => ({ input: [{ id: connectionBoundId, type: "message" }] }),
} as never,
);
expect((returnedPayload as { input: Array<Record<string, unknown>> }).input[0]?.id).toMatch(
/^msg_[a-f0-9]{16}$/,
);
});
it("adds Copilot headers for Chat Completions models", () => {
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);
const wrapped = requireStreamFn(wrapCopilotOpenAICompletionsStream(baseStreamFn));
const messages = [
{
role: "user",
content: [
{ type: "text", text: "look" },
{ type: "image", data: "abc", mimeType: "image/png" },
],
},
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
messages,
hasImages: true,
});
void wrapped(
{
provider: "github-copilot",
api: "openai-completions",
id: "gemini-3.1-pro-preview",
} as never,
{ messages } as never,
{ headers: { "X-Test": "1" } },
);
const options = requireFirstStreamOptions(baseStreamFn, "Copilot Chat Completions stream");
expect(options).toEqual({
headers: {
...expectedCopilotHeaders,
"X-Test": "1",
},
});
});
it("adapts provider stream context without changing wrapper behavior", () => {
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);
const wrapped = requireStreamFn(
wrapCopilotProviderStream({
streamFn: baseStreamFn,
} as never),
);
void wrapped(
{
provider: "github-copilot",
api: "openai-responses",
id: "gpt-4.1",
} as never,
{ messages: [{ role: "user", content: "hi" }] } as never,
{},
);
expect(baseStreamFn).toHaveBeenCalledOnce();
});
it("does not claim provider transport before OpenClaw chooses one", () => {
expect(
wrapCopilotProviderStream({
streamFn: undefined,
} as never),
).toBeUndefined();
});
});