feat: add GitHub Copilot provider

Copilot device login + onboarding option; model list auth detection.
This commit is contained in:
Mustafa Tag Eldeen
2026-01-11 05:19:07 +02:00
committed by Peter Steinberger
parent 717a259056
commit 3da1afed68
19 changed files with 926 additions and 1122 deletions

View File

@@ -1,114 +1,39 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import {
applyGoogleTurnOrderingFix,
buildEmbeddedSandboxInfo,
createSystemPromptOverride,
getDmHistoryLimitFromSessionKey,
limitHistoryTurns,
runEmbeddedPiAgent,
splitSdkTools,
} from "./pi-embedded-runner.js";
import type { SandboxContext } from "./sandbox.js";
vi.mock("@mariozechner/pi-ai", async () => {
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai")>(
"@mariozechner/pi-ai",
);
vi.mock("./model-auth.js", () => ({
getApiKeyForModel: vi.fn(),
ensureAuthProfileStore: vi.fn(() => ({ profiles: {} })),
resolveAuthProfileOrder: vi.fn(() => []),
}));
vi.mock("../providers/github-copilot-token.js", async () => {
const actual = await vi.importActual<
typeof import("../providers/github-copilot-token.js")
>("../providers/github-copilot-token.js");
return {
...actual,
streamSimple: (model: { api: string; provider: string; id: string }) => {
if (model.id === "mock-error") {
throw new Error("boom");
}
const stream = new actual.AssistantMessageEventStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "ok" }],
stopReason: "stop",
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
timestamp: Date.now(),
},
});
});
return stream;
},
resolveCopilotApiToken: vi.fn(),
};
});
const makeOpenAiConfig = (modelIds: string[]) =>
({
models: {
providers: {
openai: {
api: "openai-responses",
apiKey: "sk-test",
baseUrl: "https://example.com",
models: modelIds.map((id) => ({
id,
name: `Mock ${id}`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 16_000,
maxTokens: 2048,
})),
},
},
},
}) satisfies ClawdbotConfig;
const textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
if (Array.isArray(content) && content[0]?.type === "text") {
return (content[0] as { text?: string }).text;
}
return undefined;
};
const readSessionMessages = async (sessionFile: string) => {
const raw = await fs.readFile(sessionFile, "utf-8");
return raw
.split(/\r?\n/)
.filter(Boolean)
.map(
(line) =>
JSON.parse(line) as {
type?: string;
message?: { role?: string; content?: unknown };
},
)
.filter((entry) => entry.type === "message")
.map((entry) => entry.message as { role?: string; content?: unknown });
};
describe("buildEmbeddedSandboxInfo", () => {
it("returns undefined when sandbox is missing", () => {
expect(buildEmbeddedSandboxInfo()).toBeUndefined();
@@ -135,7 +60,7 @@ describe("buildEmbeddedSandboxInfo", () => {
env: { LANG: "C.UTF-8" },
},
tools: {
allow: ["exec"],
allow: ["bash"],
deny: ["browser"],
},
browserAllowHostControl: true,
@@ -178,7 +103,7 @@ describe("buildEmbeddedSandboxInfo", () => {
env: { LANG: "C.UTF-8" },
},
tools: {
allow: ["exec"],
allow: ["bash"],
deny: ["browser"],
},
browserAllowHostControl: false,
@@ -262,7 +187,7 @@ function createStubTool(name: string): AgentTool {
describe("splitSdkTools", () => {
const tools = [
createStubTool("read"),
createStubTool("exec"),
createStubTool("bash"),
createStubTool("edit"),
createStubTool("write"),
createStubTool("browser"),
@@ -276,7 +201,7 @@ describe("splitSdkTools", () => {
expect(builtInTools).toEqual([]);
expect(customTools.map((tool) => tool.name)).toEqual([
"read",
"exec",
"bash",
"edit",
"write",
"browser",
@@ -291,7 +216,7 @@ describe("splitSdkTools", () => {
expect(builtInTools).toEqual([]);
expect(customTools.map((tool) => tool.name)).toEqual([
"read",
"exec",
"bash",
"edit",
"write",
"browser",
@@ -317,7 +242,7 @@ describe("applyGoogleTurnOrderingFix", () => {
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_1", name: "exec", arguments: {} },
{ type: "toolCall", id: "call_1", name: "bash", arguments: {} },
],
},
] satisfies AgentMessage[];
@@ -372,281 +297,50 @@ describe("applyGoogleTurnOrderingFix", () => {
});
});
describe("limitHistoryTurns", () => {
const makeMessages = (roles: ("user" | "assistant")[]): AgentMessage[] =>
roles.map((role, i) => ({
role,
content: [{ type: "text", text: `message ${i}` }],
}));
it("returns all messages when limit is undefined", () => {
const messages = makeMessages(["user", "assistant", "user", "assistant"]);
expect(limitHistoryTurns(messages, undefined)).toBe(messages);
});
it("returns all messages when limit is 0", () => {
const messages = makeMessages(["user", "assistant", "user", "assistant"]);
expect(limitHistoryTurns(messages, 0)).toBe(messages);
});
it("returns all messages when limit is negative", () => {
const messages = makeMessages(["user", "assistant", "user", "assistant"]);
expect(limitHistoryTurns(messages, -1)).toBe(messages);
});
it("returns empty array when messages is empty", () => {
expect(limitHistoryTurns([], 5)).toEqual([]);
});
it("keeps all messages when fewer user turns than limit", () => {
const messages = makeMessages(["user", "assistant", "user", "assistant"]);
expect(limitHistoryTurns(messages, 10)).toBe(messages);
});
it("limits to last N user turns", () => {
const messages = makeMessages([
"user",
"assistant",
"user",
"assistant",
"user",
"assistant",
]);
const limited = limitHistoryTurns(messages, 2);
expect(limited.length).toBe(4);
expect(limited[0].content).toEqual([{ type: "text", text: "message 2" }]);
});
it("handles single user turn limit", () => {
const messages = makeMessages([
"user",
"assistant",
"user",
"assistant",
"user",
"assistant",
]);
const limited = limitHistoryTurns(messages, 1);
expect(limited.length).toBe(2);
expect(limited[0].content).toEqual([{ type: "text", text: "message 4" }]);
expect(limited[1].content).toEqual([{ type: "text", text: "message 5" }]);
});
it("handles messages with multiple assistant responses per user turn", () => {
const messages = makeMessages([
"user",
"assistant",
"assistant",
"user",
"assistant",
]);
const limited = limitHistoryTurns(messages, 1);
expect(limited.length).toBe(2);
expect(limited[0].role).toBe("user");
expect(limited[1].role).toBe("assistant");
});
it("preserves message content integrity", () => {
const messages: AgentMessage[] = [
{ role: "user", content: [{ type: "text", text: "first" }] },
{
role: "assistant",
content: [{ type: "toolCall", id: "1", name: "exec", arguments: {} }],
},
{ role: "user", content: [{ type: "text", text: "second" }] },
{ role: "assistant", content: [{ type: "text", text: "response" }] },
];
const limited = limitHistoryTurns(messages, 1);
expect(limited[0].content).toEqual([{ type: "text", text: "second" }]);
expect(limited[1].content).toEqual([{ type: "text", text: "response" }]);
});
});
describe("getDmHistoryLimitFromSessionKey", () => {
it("returns undefined when sessionKey is undefined", () => {
expect(getDmHistoryLimitFromSessionKey(undefined, {})).toBeUndefined();
});
it("returns undefined when config is undefined", () => {
expect(
getDmHistoryLimitFromSessionKey("telegram:dm:123", undefined),
).toBeUndefined();
});
it("returns dmHistoryLimit for telegram provider", () => {
const config = { telegram: { dmHistoryLimit: 15 } } as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(15);
});
it("returns dmHistoryLimit for whatsapp provider", () => {
const config = { whatsapp: { dmHistoryLimit: 20 } } as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("whatsapp:dm:123", config)).toBe(20);
});
it("returns dmHistoryLimit for agent-prefixed session keys", () => {
const config = { telegram: { dmHistoryLimit: 10 } } as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123", config),
).toBe(10);
});
it("returns undefined for non-dm session kinds", () => {
const config = {
slack: { dmHistoryLimit: 10 },
telegram: { dmHistoryLimit: 15 },
} as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:C1", config),
).toBeUndefined();
expect(
getDmHistoryLimitFromSessionKey("telegram:slash:123", config),
).toBeUndefined();
});
it("returns undefined for unknown provider", () => {
const config = { telegram: { dmHistoryLimit: 15 } } as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey("unknown:dm:123", config),
).toBeUndefined();
});
it("returns undefined when provider config has no dmHistoryLimit", () => {
const config = { telegram: {} } as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey("telegram:dm:123", config),
).toBeUndefined();
});
it("handles all supported providers", () => {
const providers = [
"telegram",
"whatsapp",
"discord",
"slack",
"signal",
"imessage",
"msteams",
] as const;
for (const provider of providers) {
const config = { [provider]: { dmHistoryLimit: 5 } } as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey(`${provider}:dm:123`, config),
).toBe(5);
}
});
it("handles per-DM overrides for all supported providers", () => {
const providers = [
"telegram",
"whatsapp",
"discord",
"slack",
"signal",
"imessage",
"msteams",
] as const;
for (const provider of providers) {
// Test per-DM override takes precedence
const configWithOverride = {
[provider]: {
dmHistoryLimit: 20,
dms: { user123: { historyLimit: 7 } },
},
} as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey(
`${provider}:dm:user123`,
configWithOverride,
),
).toBe(7);
// Test fallback to provider default when user not in dms
expect(
getDmHistoryLimitFromSessionKey(
`${provider}:dm:otheruser`,
configWithOverride,
),
).toBe(20);
// Test with agent-prefixed key
expect(
getDmHistoryLimitFromSessionKey(
`agent:main:${provider}:dm:user123`,
configWithOverride,
),
).toBe(7);
}
});
it("returns per-DM override when set", () => {
const config = {
telegram: {
dmHistoryLimit: 15,
dms: { "123": { historyLimit: 5 } },
},
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(5);
});
it("falls back to provider default when per-DM not set", () => {
const config = {
telegram: {
dmHistoryLimit: 15,
dms: { "456": { historyLimit: 5 } },
},
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(15);
});
it("returns per-DM override for agent-prefixed keys", () => {
const config = {
telegram: {
dmHistoryLimit: 20,
dms: { "789": { historyLimit: 3 } },
},
} as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:789", config),
).toBe(3);
});
it("handles userId with colons (e.g., email)", () => {
const config = {
msteams: {
dmHistoryLimit: 10,
dms: { "user@example.com": { historyLimit: 7 } },
},
} as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey("msteams:dm:user@example.com", config),
).toBe(7);
});
it("returns undefined when per-DM historyLimit is not set", () => {
const config = {
telegram: {
dms: { "123": {} },
},
} as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey("telegram:dm:123", config),
).toBeUndefined();
});
it("returns 0 when per-DM historyLimit is explicitly 0 (unlimited)", () => {
const config = {
telegram: {
dmHistoryLimit: 15,
dms: { "123": { historyLimit: 0 } },
},
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(0);
});
});
describe("runEmbeddedPiAgent", () => {
it("exchanges github token for copilot token", async () => {
const { getApiKeyForModel } = await import("./model-auth.js");
const { resolveCopilotApiToken } = await import(
"../providers/github-copilot-token.js"
);
vi.mocked(getApiKeyForModel).mockResolvedValue({
apiKey: "gh-token",
source: "test",
});
vi.mocked(resolveCopilotApiToken).mockResolvedValue({
token: "copilot-token",
expiresAt: Date.now() + 60_000,
source: "test",
});
const agentDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-agent-copilot-"),
);
const workspaceDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-workspace-copilot-"),
);
const sessionFile = path.join(workspaceDir, "session.jsonl");
await expect(
runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:dev:test",
sessionFile,
workspaceDir,
prompt: "hi",
provider: "github-copilot",
model: "gpt-4o",
timeoutMs: 1,
agentDir,
}),
).rejects.toThrow();
expect(resolveCopilotApiToken).toHaveBeenCalledWith({
githubToken: "gh-token",
});
});
it("writes models.json into the provided agentDir", async () => {
const agentDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-agent-"),
@@ -660,12 +354,12 @@ describe("runEmbeddedPiAgent", () => {
models: {
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
baseUrl: "https://api.minimax.io/v1",
api: "openai-completions",
apiKey: "sk-minimax-test",
models: [
{
id: "MiniMax-M2.1",
id: "minimax-m2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
@@ -698,216 +392,4 @@ describe("runEmbeddedPiAgent", () => {
fs.stat(path.join(agentDir, "models.json")),
).resolves.toBeTruthy();
});
it("persists the first user message before assistant output", async () => {
const agentDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-agent-"),
);
const workspaceDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-workspace-"),
);
const sessionFile = path.join(workspaceDir, "session.jsonl");
const cfg = makeOpenAiConfig(["mock-1"]);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:main:main",
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
});
const messages = await readSessionMessages(sessionFile);
const firstUserIndex = messages.findIndex(
(message) =>
message?.role === "user" &&
textFromContent(message.content) === "hello",
);
const firstAssistantIndex = messages.findIndex(
(message) => message?.role === "assistant",
);
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
if (firstAssistantIndex !== -1) {
expect(firstUserIndex).toBeLessThan(firstAssistantIndex);
}
});
it("persists the user message when prompt fails before assistant output", async () => {
const agentDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-agent-"),
);
const workspaceDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-workspace-"),
);
const sessionFile = path.join(workspaceDir, "session.jsonl");
const cfg = makeOpenAiConfig(["mock-error"]);
const result = await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:main:main",
sessionFile,
workspaceDir,
config: cfg,
prompt: "boom",
provider: "openai",
model: "mock-error",
timeoutMs: 5_000,
agentDir,
});
expect(result.payloads[0]?.isError).toBe(true);
const messages = await readSessionMessages(sessionFile);
const userIndex = messages.findIndex(
(message) =>
message?.role === "user" && textFromContent(message.content) === "boom",
);
expect(userIndex).toBeGreaterThanOrEqual(0);
});
it("appends new user + assistant after existing transcript entries", async () => {
const agentDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-agent-"),
);
const workspaceDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-workspace-"),
);
const sessionFile = path.join(workspaceDir, "session.jsonl");
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "seed user" }],
});
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: "seed assistant" }],
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "mock-1",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
timestamp: Date.now(),
});
const cfg = makeOpenAiConfig(["mock-1"]);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:main:main",
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
});
const messages = await readSessionMessages(sessionFile);
const seedUserIndex = messages.findIndex(
(message) =>
message?.role === "user" &&
textFromContent(message.content) === "seed user",
);
const seedAssistantIndex = messages.findIndex(
(message) =>
message?.role === "assistant" &&
textFromContent(message.content) === "seed assistant",
);
const newUserIndex = messages.findIndex(
(message) =>
message?.role === "user" &&
textFromContent(message.content) === "hello",
);
const newAssistantIndex = messages.findIndex(
(message, index) => index > newUserIndex && message?.role === "assistant",
);
expect(seedUserIndex).toBeGreaterThanOrEqual(0);
expect(seedAssistantIndex).toBeGreaterThan(seedUserIndex);
expect(newUserIndex).toBeGreaterThan(seedAssistantIndex);
expect(newAssistantIndex).toBeGreaterThan(newUserIndex);
});
it("persists multi-turn user/assistant ordering across runs", async () => {
const agentDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-agent-"),
);
const workspaceDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-workspace-"),
);
const sessionFile = path.join(workspaceDir, "session.jsonl");
const cfg = makeOpenAiConfig(["mock-1"]);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:main:main",
sessionFile,
workspaceDir,
config: cfg,
prompt: "first",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
});
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:main:main",
sessionFile,
workspaceDir,
config: cfg,
prompt: "second",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
});
const messages = await readSessionMessages(sessionFile);
const firstUserIndex = messages.findIndex(
(message) =>
message?.role === "user" &&
textFromContent(message.content) === "first",
);
const firstAssistantIndex = messages.findIndex(
(message, index) =>
index > firstUserIndex && message?.role === "assistant",
);
const secondUserIndex = messages.findIndex(
(message) =>
message?.role === "user" &&
textFromContent(message.content) === "second",
);
const secondAssistantIndex = messages.findIndex(
(message, index) =>
index > secondUserIndex && message?.role === "assistant",
);
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
expect(firstAssistantIndex).toBeGreaterThan(firstUserIndex);
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
});
});