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

@@ -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(() => {

View File

@@ -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 {

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);
});
});

View File

@@ -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;
};