mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-02 15:21:04 +00:00
Adds the opt-in bundled GitHub Copilot agent runtime, pinned SDK install path, docs/inventory, SDK/tool/sandbox/auth wiring, and replay/tool-safety fixes.
Verification:
- Local: git diff --check; fnm exec --using 24.15.0 pnpm tsgo:extensions; fnm exec --using 24.15.0 pnpm check:test-types; fnm exec --using 24.15.0 pnpm build.
- Autoreview local: clean for the replay-safety fix; branch autoreview engine returned empty output twice, so local autoreview plus local/Crabbox/CI proof was used.
- Crabbox focused Copilot: run_2c0db9f48a4a, 19 files / 485 tests passed.
- Crabbox additional boundary shard: run_26a246a1aa24, prompt snapshots and plugin SDK boundary/export checks passed.
- Crabbox live Copilot: run_d128e4048b4e, real gpt-4.1 turn with live_echo phase-1-green and clean session-file check.
- GitHub checks: green on head 7cc8657e0d, including Dependency Guard after exact-head approval.
Co-authored-by: Ramraj Balasubramanian <ramrajba@microsoft.com>
377 lines
12 KiB
TypeScript
Executable File
377 lines
12 KiB
TypeScript
Executable File
import { createHash } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
import {
|
|
initializeGlobalHookRunner,
|
|
resetGlobalHookRunner,
|
|
} from "openclaw/plugin-sdk/hook-runtime";
|
|
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
import {
|
|
castAgentMessage,
|
|
makeAgentAssistantMessage,
|
|
makeAgentUserMessage,
|
|
} from "openclaw/plugin-sdk/test-fixtures";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
attachCopilotMirrorIdentity,
|
|
dualWriteCopilotTranscriptBestEffort,
|
|
mirrorCopilotTranscript,
|
|
} from "./dual-write-transcripts.js";
|
|
|
|
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
|
|
|
|
function expectedFingerprint(message: MirroredAgentMessage): string {
|
|
const payload = JSON.stringify({ role: message.role, content: message.content });
|
|
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
|
|
}
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
resetGlobalHookRunner();
|
|
for (const dir of tempDirs.splice(0)) {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
async function createTempSessionFile() {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-copilot-mirror-"));
|
|
tempDirs.push(dir);
|
|
return path.join(dir, "session.jsonl");
|
|
}
|
|
|
|
async function makeRoot(prefix: string): Promise<string> {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
tempDirs.push(root);
|
|
return root;
|
|
}
|
|
|
|
function parseJsonLines<T>(raw: string): T[] {
|
|
const records: T[] = [];
|
|
for (const line of raw.trim().split("\n")) {
|
|
if (line.length > 0) {
|
|
records.push(JSON.parse(line) as T);
|
|
}
|
|
}
|
|
return records;
|
|
}
|
|
|
|
describe("mirrorCopilotTranscript", () => {
|
|
it("mirrors user, assistant, and tool result messages into the OpenClaw transcript", async () => {
|
|
const sessionFile = await createTempSessionFile();
|
|
const userMessage = makeAgentUserMessage({
|
|
content: [{ type: "text", text: "hello" }],
|
|
timestamp: Date.now(),
|
|
});
|
|
const assistantMessage = makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "hi there" }],
|
|
timestamp: Date.now() + 1,
|
|
});
|
|
const toolResultMessage = castAgentMessage({
|
|
role: "toolResult",
|
|
toolCallId: "call-1",
|
|
toolName: "read",
|
|
content: [
|
|
{
|
|
type: "toolResult",
|
|
toolCallId: "call-1",
|
|
content: "read output",
|
|
},
|
|
],
|
|
timestamp: Date.now() + 2,
|
|
}) as MirroredAgentMessage;
|
|
|
|
await mirrorCopilotTranscript({
|
|
sessionFile,
|
|
sessionKey: "session-1",
|
|
messages: [userMessage, assistantMessage, toolResultMessage],
|
|
idempotencyScope: "copilot:session-1",
|
|
});
|
|
|
|
const raw = await fs.readFile(sessionFile, "utf8");
|
|
expect(raw).toContain('"role":"user"');
|
|
expect(raw).toContain('"role":"assistant"');
|
|
expect(raw).toContain('"role":"toolResult"');
|
|
expect(raw).toContain('"toolCallId":"call-1"');
|
|
expect(raw).toContain(
|
|
`"idempotencyKey":"copilot:session-1:user:${expectedFingerprint(userMessage)}"`,
|
|
);
|
|
expect(raw).toContain(
|
|
`"idempotencyKey":"copilot:session-1:assistant:${expectedFingerprint(assistantMessage)}"`,
|
|
);
|
|
expect(raw).toContain(
|
|
`"idempotencyKey":"copilot:session-1:toolResult:${expectedFingerprint(toolResultMessage)}"`,
|
|
);
|
|
});
|
|
|
|
it("creates the transcript directory on first mirror", async () => {
|
|
const root = await makeRoot("openclaw-copilot-mirror-missing-dir-");
|
|
const sessionFile = path.join(root, "nested", "sessions", "session.jsonl");
|
|
|
|
await mirrorCopilotTranscript({
|
|
sessionFile,
|
|
sessionKey: "session-1",
|
|
messages: [
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "first mirror" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
],
|
|
idempotencyScope: "copilot:session-1",
|
|
});
|
|
|
|
const raw = await fs.readFile(sessionFile, "utf8");
|
|
expect(raw).toContain('"role":"assistant"');
|
|
expect(raw).toContain('"content":[{"type":"text","text":"first mirror"}]');
|
|
});
|
|
|
|
it("deduplicates re-emits by idempotency scope", async () => {
|
|
const sessionFile = await createTempSessionFile();
|
|
const messages = [
|
|
makeAgentUserMessage({
|
|
content: [{ type: "text", text: "hello" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "hi there" }],
|
|
timestamp: Date.now() + 1,
|
|
}),
|
|
] as const;
|
|
|
|
await mirrorCopilotTranscript({
|
|
sessionFile,
|
|
sessionKey: "session-1",
|
|
messages: [...messages],
|
|
idempotencyScope: "copilot:session-1",
|
|
});
|
|
await mirrorCopilotTranscript({
|
|
sessionFile,
|
|
sessionKey: "session-1",
|
|
messages: [...messages],
|
|
idempotencyScope: "copilot:session-1",
|
|
});
|
|
|
|
const records = parseJsonLines<{ type?: string; message?: { role?: string } }>(
|
|
await fs.readFile(sessionFile, "utf8"),
|
|
);
|
|
// First "header" record may or may not appear depending on migration.
|
|
// What matters is that the second mirror call adds zero new messages.
|
|
const messageRecords = records.filter((r) => r.message?.role !== undefined);
|
|
expect(messageRecords).toHaveLength(2);
|
|
});
|
|
|
|
it("runs before_message_write before appending mirrored messages", async () => {
|
|
initializeGlobalHookRunner(
|
|
createMockPluginRegistry([
|
|
{
|
|
hookName: "before_message_write",
|
|
handler: (event) => ({
|
|
message: castAgentMessage({
|
|
...((event as { message: unknown }).message as Record<string, unknown>),
|
|
content: [{ type: "text", text: "hello [hooked]" }],
|
|
}),
|
|
}),
|
|
},
|
|
]),
|
|
);
|
|
const sessionFile = await createTempSessionFile();
|
|
const sourceMessage = makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "hello" }],
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
await mirrorCopilotTranscript({
|
|
sessionFile,
|
|
sessionKey: "session-1",
|
|
messages: [sourceMessage],
|
|
idempotencyScope: "copilot:session-1",
|
|
});
|
|
|
|
const raw = await fs.readFile(sessionFile, "utf8");
|
|
expect(raw).toContain('"content":[{"type":"text","text":"hello [hooked]"}]');
|
|
expect(raw).toContain(
|
|
`"idempotencyKey":"copilot:session-1:assistant:${expectedFingerprint(sourceMessage)}"`,
|
|
);
|
|
});
|
|
|
|
it("respects before_message_write blocking decisions", async () => {
|
|
initializeGlobalHookRunner(
|
|
createMockPluginRegistry([
|
|
{
|
|
hookName: "before_message_write",
|
|
handler: () => ({ block: true }),
|
|
},
|
|
]),
|
|
);
|
|
const sessionFile = await createTempSessionFile();
|
|
|
|
await mirrorCopilotTranscript({
|
|
sessionFile,
|
|
sessionKey: "session-1",
|
|
messages: [
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "should not persist" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
],
|
|
idempotencyScope: "copilot:session-1",
|
|
});
|
|
|
|
await expect(fs.readFile(sessionFile, "utf8")).rejects.toHaveProperty("code", "ENOENT");
|
|
});
|
|
|
|
it("is a no-op when no mirrorable messages are present", async () => {
|
|
const sessionFile = await createTempSessionFile();
|
|
|
|
await mirrorCopilotTranscript({
|
|
sessionFile,
|
|
sessionKey: "session-1",
|
|
messages: [],
|
|
idempotencyScope: "copilot:session-1",
|
|
});
|
|
|
|
await expect(fs.readFile(sessionFile, "utf8")).rejects.toHaveProperty("code", "ENOENT");
|
|
});
|
|
|
|
it("uses content fingerprint when no explicit mirror identity is attached", async () => {
|
|
const sessionFile = await createTempSessionFile();
|
|
const message = makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "fp" }],
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
await mirrorCopilotTranscript({
|
|
sessionFile,
|
|
messages: [message],
|
|
idempotencyScope: "scope-fp",
|
|
});
|
|
|
|
const raw = await fs.readFile(sessionFile, "utf8");
|
|
expect(raw).toContain(`"idempotencyKey":"scope-fp:assistant:${expectedFingerprint(message)}"`);
|
|
});
|
|
|
|
it("uses attached identity instead of content fingerprint when provided", async () => {
|
|
const sessionFile = await createTempSessionFile();
|
|
const baseMessage = makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "explicit" }],
|
|
timestamp: Date.now(),
|
|
});
|
|
const tagged = attachCopilotMirrorIdentity(baseMessage, "sdk-session-1:assistant:0");
|
|
|
|
await mirrorCopilotTranscript({
|
|
sessionFile,
|
|
messages: [tagged],
|
|
idempotencyScope: "copilot:openclaw-session-1",
|
|
});
|
|
|
|
const raw = await fs.readFile(sessionFile, "utf8");
|
|
expect(raw).toContain(
|
|
'"idempotencyKey":"copilot:openclaw-session-1:sdk-session-1:assistant:0"',
|
|
);
|
|
expect(raw).not.toContain(expectedFingerprint(baseMessage));
|
|
});
|
|
|
|
it("omits idempotencyKey when no idempotencyScope is provided", async () => {
|
|
const sessionFile = await createTempSessionFile();
|
|
|
|
await mirrorCopilotTranscript({
|
|
sessionFile,
|
|
messages: [
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "no scope" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
],
|
|
});
|
|
|
|
const raw = await fs.readFile(sessionFile, "utf8");
|
|
expect(raw).toContain('"content":[{"type":"text","text":"no scope"}]');
|
|
expect(raw).not.toContain("idempotencyKey");
|
|
});
|
|
|
|
it("filters out non-mirrorable roles", async () => {
|
|
const sessionFile = await createTempSessionFile();
|
|
const userMessage = makeAgentUserMessage({
|
|
content: [{ type: "text", text: "u" }],
|
|
timestamp: Date.now(),
|
|
});
|
|
const systemLike = castAgentMessage({
|
|
role: "system" as never,
|
|
content: [{ type: "text", text: "system note" }],
|
|
timestamp: Date.now() + 1,
|
|
});
|
|
|
|
await mirrorCopilotTranscript({
|
|
sessionFile,
|
|
messages: [userMessage, systemLike],
|
|
idempotencyScope: "scope",
|
|
});
|
|
|
|
const raw = await fs.readFile(sessionFile, "utf8");
|
|
expect(raw).toContain('"role":"user"');
|
|
expect(raw).not.toContain("system note");
|
|
});
|
|
|
|
it("preserves explicit identity across attachCopilotMirrorIdentity overrides", async () => {
|
|
const sessionFile = await createTempSessionFile();
|
|
const base = makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "x" }],
|
|
timestamp: Date.now(),
|
|
});
|
|
const first = attachCopilotMirrorIdentity(base, "id-1");
|
|
const second = attachCopilotMirrorIdentity(first, "id-2");
|
|
|
|
await mirrorCopilotTranscript({
|
|
sessionFile,
|
|
messages: [second],
|
|
idempotencyScope: "scope",
|
|
});
|
|
|
|
const raw = await fs.readFile(sessionFile, "utf8");
|
|
expect(raw).toContain('"idempotencyKey":"scope:id-2"');
|
|
expect(raw).not.toContain('"idempotencyKey":"scope:id-1"');
|
|
});
|
|
});
|
|
|
|
describe("dualWriteCopilotTranscriptBestEffort", () => {
|
|
it("returns normally when mirror succeeds", async () => {
|
|
const sessionFile = await createTempSessionFile();
|
|
await expect(
|
|
dualWriteCopilotTranscriptBestEffort({
|
|
sessionFile,
|
|
messages: [
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "ok" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
],
|
|
idempotencyScope: "scope",
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
const raw = await fs.readFile(sessionFile, "utf8");
|
|
expect(raw).toContain('"role":"assistant"');
|
|
});
|
|
|
|
it("swallows infrastructure failures and never rejects", async () => {
|
|
// Pointing sessionFile at a path under a non-existent root with an
|
|
// empty-string segment can fail differently on different platforms;
|
|
// instead force failure by passing an invalid type and asserting
|
|
// that the wrapper itself does not reject. Use any-cast for the
|
|
// bad input shape since we are testing the wrapper's catch.
|
|
await expect(
|
|
dualWriteCopilotTranscriptBestEffort({
|
|
sessionFile: "" as unknown as string,
|
|
messages: [
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "should-not-throw" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
],
|
|
idempotencyScope: "scope",
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|