diff --git a/extensions/github-copilot/connection-bound-ids.live.test.ts b/extensions/github-copilot/connection-bound-ids.live.test.ts new file mode 100644 index 00000000000..8ca6a3a2c27 --- /dev/null +++ b/extensions/github-copilot/connection-bound-ids.live.test.ts @@ -0,0 +1,177 @@ +import { streamOpenAIResponses, type AssistantMessage, type Model } from "@mariozechner/pi-ai"; +import { buildCopilotDynamicHeaders } from "openclaw/plugin-sdk/provider-stream-shared"; +import { describe, expect, it } from "vitest"; +import { wrapCopilotOpenAIResponsesStream } from "./stream.js"; +import { resolveCopilotApiToken } from "./token.js"; + +const LIVE = + process.env.OPENCLAW_LIVE_TEST === "1" || + process.env.LIVE === "1" || + process.env.GITHUB_COPILOT_LIVE_TEST === "1"; +const GITHUB_TOKEN = + process.env.OPENCLAW_LIVE_GITHUB_COPILOT_TOKEN ?? + process.env.COPILOT_GITHUB_TOKEN ?? + process.env.GH_TOKEN ?? + process.env.GITHUB_TOKEN ?? + ""; +const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_GITHUB_COPILOT_MODEL?.trim() || "gpt-5.4"; +const describeLive = LIVE && GITHUB_TOKEN.trim().length > 0 ? describe : describe.skip; + +const ZERO_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +} as const; + +function logProgress(message: string): void { + process.stderr.write(`[github-copilot-live] ${message}\n`); +} + +async function withTimeout(label: string, promise: Promise, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + timer.unref?.(); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +const fetchWithTimeout: typeof fetch = async (input, init) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10_000); + timer.unref?.(); + try { + return await fetch(input, { + ...init, + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } +}; + +function buildModel(baseUrl: string): Model<"openai-responses"> { + return { + id: LIVE_MODEL_ID, + name: LIVE_MODEL_ID, + provider: "github-copilot", + api: "openai-responses", + baseUrl, + headers: {}, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 256, + }; +} + +function buildReplayAssistantMessage(connectionBoundId: string): AssistantMessage { + return { + role: "assistant", + api: "openai-responses", + provider: "github-copilot", + model: LIVE_MODEL_ID, + usage: ZERO_USAGE, + stopReason: "stop", + timestamp: Date.now() - 1, + content: [ + { + type: "text", + text: "Earlier assistant text.", + textSignature: JSON.stringify({ v: 1, id: connectionBoundId }), + }, + ], + }; +} + +function extractText(response: unknown): string { + const content = (response as { content?: Array<{ type?: string; text?: string }> }).content; + if (!Array.isArray(content)) { + return ""; + } + return content + .filter((block) => block.type === "text") + .map((block) => block.text?.trim() ?? "") + .filter(Boolean) + .join(" "); +} + +describeLive("github-copilot connection-bound Responses IDs live", () => { + it("rewrites replayed connection-bound item IDs before sending to Copilot", async () => { + logProgress("start"); + let token; + try { + logProgress("exchanging GitHub token for Copilot token"); + token = await withTimeout( + "Copilot token exchange", + resolveCopilotApiToken({ + githubToken: GITHUB_TOKEN, + fetchImpl: fetchWithTimeout, + }), + 15_000, + ); + } catch (error) { + logProgress(`skip (${error instanceof Error ? error.message : String(error)})`); + return; + } + logProgress(`token ok (${token.source.startsWith("cache:") ? "cache" : "fetched"})`); + + const model = buildModel(token.baseUrl); + const staleId = Buffer.from(`copilot-${"x".repeat(24)}`).toString("base64"); + const context = { + messages: [ + buildReplayAssistantMessage(staleId), + { + role: "user" as const, + content: "Reply with exactly: COPILOT_LIVE_OK", + timestamp: Date.now(), + }, + ], + }; + let capturedPayload: Record | undefined; + + const stream = wrapCopilotOpenAIResponsesStream(streamOpenAIResponses as never)( + model as never, + context as never, + { + apiKey: token.token, + headers: buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages: false, + }), + maxTokens: 32, + onPayload: (payload: unknown) => { + capturedPayload = payload as Record; + }, + } as never, + ) as { result(): Promise }; + + logProgress("sending Responses request"); + const result = await stream.result(); + logProgress("Responses request completed"); + const input = Array.isArray(capturedPayload?.input) ? capturedPayload.input : []; + const replayedAssistant = input.find( + (item): item is Record => + !!item && typeof item === "object" && (item as Record).type === "message", + ); + + expect(replayedAssistant?.id).toMatch(/^msg_[a-f0-9]{16}$/); + expect(replayedAssistant?.id).not.toBe(staleId); + expect(extractText(result)).toMatch(/^COPILOT_LIVE_OK[.!]?$/i); + }, 60_000); +});