feat: add GitHub Copilot provider
Copilot device login + onboarding option; model list auth detection.
This commit is contained in:
committed by
Peter Steinberger
parent
717a259056
commit
3da1afed68
@@ -34,6 +34,48 @@ const MODELS_CONFIG: ClawdbotConfig = {
|
||||
};
|
||||
|
||||
describe("models config", () => {
|
||||
it("auto-injects github-copilot provider when token is present", async () => {
|
||||
await withTempHome(async () => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
||||
|
||||
try {
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL:
|
||||
"https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||
token: "copilot",
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
await ensureClawdbotModelsJson({ models: { providers: {} } });
|
||||
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const raw = await fs.readFile(
|
||||
path.join(agentDir, "models.json"),
|
||||
"utf8",
|
||||
);
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
|
||||
};
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
||||
"https://api.copilot.example",
|
||||
);
|
||||
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
|
||||
} finally {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
let previousHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -2,62 +2,27 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
|
||||
import type { ModelsConfig as ModelsConfigShape } from "../config/types.js";
|
||||
import {
|
||||
DEFAULT_COPILOT_API_BASE_URL,
|
||||
resolveCopilotApiToken,
|
||||
} from "../providers/github-copilot-token.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
||||
import { resolveEnvApiKey } from "./model-auth.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
} from "./auth-profiles.js";
|
||||
|
||||
type ModelsConfig = NonNullable<ClawdbotConfig["models"]>;
|
||||
type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
|
||||
|
||||
type ModelsProviderConfig = NonNullable<ModelsConfigShape["providers"]>[string];
|
||||
|
||||
const DEFAULT_MODE: NonNullable<ModelsConfig["mode"]> = "merge";
|
||||
const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic";
|
||||
const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.1";
|
||||
const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000;
|
||||
const MINIMAX_DEFAULT_MAX_TOKENS = 8192;
|
||||
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
|
||||
const MINIMAX_API_COST = {
|
||||
input: 15,
|
||||
output: 60,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 10,
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function normalizeGoogleModelId(id: string): string {
|
||||
if (id === "gemini-3-pro") return "gemini-3-pro-preview";
|
||||
if (id === "gemini-3-flash") return "gemini-3-flash-preview";
|
||||
return id;
|
||||
}
|
||||
|
||||
function normalizeGoogleProvider(provider: ProviderConfig): ProviderConfig {
|
||||
let mutated = false;
|
||||
const models = provider.models.map((model) => {
|
||||
const nextId = normalizeGoogleModelId(model.id);
|
||||
if (nextId === model.id) return model;
|
||||
mutated = true;
|
||||
return { ...model, id: nextId };
|
||||
});
|
||||
return mutated ? { ...provider, models } : provider;
|
||||
}
|
||||
|
||||
function normalizeProviders(
|
||||
providers: ModelsConfig["providers"],
|
||||
): ModelsConfig["providers"] {
|
||||
if (!providers) return providers;
|
||||
let mutated = false;
|
||||
const next: Record<string, ProviderConfig> = {};
|
||||
for (const [key, provider] of Object.entries(providers)) {
|
||||
const normalized =
|
||||
key === "google" ? normalizeGoogleProvider(provider) : provider;
|
||||
if (normalized !== provider) mutated = true;
|
||||
next[key] = normalized;
|
||||
}
|
||||
return mutated ? next : providers;
|
||||
}
|
||||
|
||||
async function readJson(pathname: string): Promise<unknown> {
|
||||
try {
|
||||
const raw = await fs.readFile(pathname, "utf8");
|
||||
@@ -67,37 +32,62 @@ async function readJson(pathname: string): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function buildMinimaxApiProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MINIMAX_API_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: MINIMAX_DEFAULT_MODEL_ID,
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveImplicitProviders(params: {
|
||||
async function maybeBuildCopilotProvider(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir: string;
|
||||
}): ModelsConfig["providers"] {
|
||||
const providers: Record<string, ProviderConfig> = {};
|
||||
const minimaxEnv = resolveEnvApiKey("minimax");
|
||||
const authStore = ensureAuthProfileStore(params.agentDir);
|
||||
const hasMinimaxProfile =
|
||||
listProfilesForProvider(authStore, "minimax").length > 0;
|
||||
if (minimaxEnv || hasMinimaxProfile) {
|
||||
providers.minimax = buildMinimaxApiProvider();
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<ModelsProviderConfig | null> {
|
||||
const env = params.env ?? process.env;
|
||||
const authStore = ensureAuthProfileStore();
|
||||
const hasProfile =
|
||||
listProfilesForProvider(authStore, "github-copilot").length > 0;
|
||||
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
|
||||
const githubToken = (envToken ?? "").trim();
|
||||
|
||||
if (!hasProfile && !githubToken) return null;
|
||||
|
||||
let selectedGithubToken = githubToken;
|
||||
if (!selectedGithubToken && hasProfile) {
|
||||
// Use the first available profile as a default for discovery (it will be
|
||||
// re-resolved per-run by the embedded runner).
|
||||
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
|
||||
const profile = profileId ? authStore.profiles[profileId] : undefined;
|
||||
if (profile && profile.type === "token") {
|
||||
selectedGithubToken = profile.token;
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
|
||||
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||
if (selectedGithubToken) {
|
||||
try {
|
||||
const token = await resolveCopilotApiToken({
|
||||
githubToken: selectedGithubToken,
|
||||
env,
|
||||
});
|
||||
baseUrl = token.baseUrl;
|
||||
} catch {
|
||||
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
||||
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
||||
// Our Copilot auth lives in Clawdbot's auth-profiles store instead, so we also
|
||||
// write a runtime-only auth.json entry for pi-coding-agent to pick up.
|
||||
//
|
||||
// This is safe because it's (1) within Clawdbot's agent dir, (2) contains the
|
||||
// GitHub token (not the exchanged Copilot token), and (3) matches existing
|
||||
// patterns for OAuth-like providers in pi-coding-agent.
|
||||
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
|
||||
// Clawdbot uses its own auth store and exchanges tokens at runtime.
|
||||
// `models list` uses Clawdbot's auth heuristics for availability.
|
||||
|
||||
// We intentionally do NOT define custom models for Copilot in models.json.
|
||||
// pi-coding-agent treats providers with models as replacements requiring apiKey.
|
||||
// We only override baseUrl; the model list comes from pi-ai built-ins.
|
||||
return {
|
||||
baseUrl,
|
||||
models: [],
|
||||
} satisfies ModelsProviderConfig;
|
||||
}
|
||||
|
||||
export async function ensureClawdbotModelsJson(
|
||||
@@ -105,17 +95,24 @@ export async function ensureClawdbotModelsJson(
|
||||
agentDirOverride?: string,
|
||||
): Promise<{ agentDir: string; wrote: boolean }> {
|
||||
const cfg = config ?? loadConfig();
|
||||
const agentDir = agentDirOverride?.trim()
|
||||
? agentDirOverride.trim()
|
||||
: resolveClawdbotAgentDir();
|
||||
const configuredProviders = cfg.models?.providers ?? {};
|
||||
const implicitProviders = resolveImplicitProviders({ cfg, agentDir });
|
||||
const providers = { ...implicitProviders, ...configuredProviders };
|
||||
if (Object.keys(providers).length === 0) {
|
||||
|
||||
const explicitProviders = cfg.models?.providers ?? {};
|
||||
const implicitCopilot = await maybeBuildCopilotProvider({ cfg });
|
||||
const providers = implicitCopilot
|
||||
? { ...explicitProviders, "github-copilot": implicitCopilot }
|
||||
: explicitProviders;
|
||||
|
||||
if (!providers || Object.keys(providers).length === 0) {
|
||||
const agentDir = agentDirOverride?.trim()
|
||||
? agentDirOverride.trim()
|
||||
: resolveClawdbotAgentDir();
|
||||
return { agentDir, wrote: false };
|
||||
}
|
||||
|
||||
const mode = cfg.models?.mode ?? DEFAULT_MODE;
|
||||
const agentDir = agentDirOverride?.trim()
|
||||
? agentDirOverride.trim()
|
||||
: resolveClawdbotAgentDir();
|
||||
const targetPath = path.join(agentDir, "models.json");
|
||||
|
||||
let mergedProviders = providers;
|
||||
@@ -131,8 +128,7 @@ export async function ensureClawdbotModelsJson(
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedProviders = normalizeProviders(mergedProviders);
|
||||
const next = `${JSON.stringify({ providers: normalizedProviders }, null, 2)}\n`;
|
||||
const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`;
|
||||
try {
|
||||
existingRaw = await fs.readFile(targetPath, "utf8");
|
||||
} catch {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1042,7 +1042,18 @@ export async function compactEmbeddedPiSession(params: {
|
||||
model,
|
||||
cfg: params.config,
|
||||
});
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
|
||||
if (model.provider === "github-copilot") {
|
||||
const { resolveCopilotApiToken } = await import(
|
||||
"../providers/github-copilot-token.js"
|
||||
);
|
||||
const copilotToken = await resolveCopilotApiToken({
|
||||
githubToken: apiKeyInfo.apiKey,
|
||||
});
|
||||
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
||||
} else {
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1432,7 +1443,19 @@ export async function runEmbeddedPiAgent(params: {
|
||||
|
||||
const applyApiKeyInfo = async (candidate?: string): Promise<void> => {
|
||||
apiKeyInfo = await resolveApiKeyForCandidate(candidate);
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
|
||||
if (model.provider === "github-copilot") {
|
||||
const { resolveCopilotApiToken } = await import(
|
||||
"../providers/github-copilot-token.js"
|
||||
);
|
||||
const copilotToken = await resolveCopilotApiToken({
|
||||
githubToken: apiKeyInfo.apiKey,
|
||||
});
|
||||
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
||||
} else {
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
}
|
||||
|
||||
lastProfileId = apiKeyInfo.profileId;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user