fix: add copilot tests and lint fixes

This commit is contained in:
Peter Steinberger
2026-01-12 17:48:08 +00:00
parent 14801b46fc
commit e91aa0657e
9 changed files with 214 additions and 36 deletions

View File

@@ -34,7 +34,7 @@ const MOONSHOT_DEFAULT_COST = {
function normalizeApiKeyConfig(value: string): string { function normalizeApiKeyConfig(value: string): string {
const trimmed = value.trim(); const trimmed = value.trim();
const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed); const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed);
return match ? match[1] : trimmed; return match?.[1] ?? trimmed;
} }
function resolveEnvApiKeyVarName(provider: string): string | undefined { function resolveEnvApiKeyVarName(provider: string): string | undefined {

View File

@@ -35,7 +35,7 @@ const MODELS_CONFIG: ClawdbotConfig = {
describe("models config", () => { describe("models config", () => {
it("auto-injects github-copilot provider when token is present", async () => { it("auto-injects github-copilot provider when token is present", async () => {
await withTempHome(async () => { await withTempHome(async (home) => {
const previous = process.env.COPILOT_GITHUB_TOKEN; const previous = process.env.COPILOT_GITHUB_TOKEN;
process.env.COPILOT_GITHUB_TOKEN = "gh-token"; process.env.COPILOT_GITHUB_TOKEN = "gh-token";
@@ -54,11 +54,10 @@ describe("models config", () => {
})); }));
const { ensureClawdbotModelsJson } = await import("./models-config.js"); const { ensureClawdbotModelsJson } = await import("./models-config.js");
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
await ensureClawdbotModelsJson({ models: { providers: {} } }); const agentDir = path.join(home, "agent-default-base-url");
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
const agentDir = resolveClawdbotAgentDir();
const raw = await fs.readFile( const raw = await fs.readFile(
path.join(agentDir, "models.json"), path.join(agentDir, "models.json"),
"utf8", "utf8",
@@ -77,6 +76,114 @@ describe("models config", () => {
}); });
}); });
it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => {
await withTempHome(async () => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN;
const previousGithub = process.env.GITHUB_TOKEN;
process.env.COPILOT_GITHUB_TOKEN = "copilot-token";
process.env.GH_TOKEN = "gh-token";
process.env.GITHUB_TOKEN = "github-token";
try {
vi.resetModules();
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
});
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL:
"https://api.individual.githubcopilot.com",
resolveCopilotApiToken,
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js");
await ensureClawdbotModelsJson({ models: { providers: {} } });
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
expect.objectContaining({ githubToken: "copilot-token" }),
);
} finally {
process.env.COPILOT_GITHUB_TOKEN = previous;
process.env.GH_TOKEN = previousGh;
process.env.GITHUB_TOKEN = previousGithub;
}
});
});
it("uses the first github-copilot profile when env tokens are missing", async () => {
await withTempHome(async (home) => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN;
const previousGithub = process.env.GITHUB_TOKEN;
delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
try {
vi.resetModules();
const agentDir = path.join(home, "agent-profiles");
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
path.join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"github-copilot:alpha": {
type: "token",
provider: "github-copilot",
token: "alpha-token",
},
"github-copilot:beta": {
type: "token",
provider: "github-copilot",
token: "beta-token",
},
},
},
null,
2,
),
);
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
});
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL:
"https://api.individual.githubcopilot.com",
resolveCopilotApiToken,
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js");
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
expect.objectContaining({ githubToken: "alpha-token" }),
);
} finally {
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
else process.env.COPILOT_GITHUB_TOKEN = previous;
if (previousGh === undefined) delete process.env.GH_TOKEN;
else process.env.GH_TOKEN = previousGh;
if (previousGithub === undefined) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = previousGithub;
}
});
});
it("does not override explicit github-copilot provider config", async () => { it("does not override explicit github-copilot provider config", async () => {
await withTempHome(async () => { await withTempHome(async () => {
const previous = process.env.COPILOT_GITHUB_TOKEN; const previous = process.env.COPILOT_GITHUB_TOKEN;
@@ -129,6 +236,42 @@ describe("models config", () => {
}); });
}); });
it("falls back to default baseUrl when token exchange fails", 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.default.test",
resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")),
}));
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 }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
"https://api.default.test",
);
} finally {
process.env.COPILOT_GITHUB_TOKEN = previous;
}
});
});
it("uses agentDir override auth profiles for copilot injection", async () => { it("uses agentDir override auth profiles for copilot injection", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const previous = process.env.COPILOT_GITHUB_TOKEN; const previous = process.env.COPILOT_GITHUB_TOKEN;
@@ -197,6 +340,50 @@ describe("models config", () => {
} }
}); });
}); });
it("skips writing models.json when no env token or profile exists", async () => {
await withTempHome(async (home) => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN;
const previousGithub = process.env.GITHUB_TOKEN;
const previousMinimax = process.env.MINIMAX_API_KEY;
const previousMoonshot = process.env.MOONSHOT_API_KEY;
delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.MINIMAX_API_KEY;
delete process.env.MOONSHOT_API_KEY;
try {
vi.resetModules();
const { ensureClawdbotModelsJson } = await import("./models-config.js");
const agentDir = path.join(home, "agent-empty");
const result = await ensureClawdbotModelsJson(
{
models: { providers: {} },
},
agentDir,
);
await expect(
fs.stat(path.join(agentDir, "models.json")),
).rejects.toThrow();
expect(result.wrote).toBe(false);
} finally {
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
else process.env.COPILOT_GITHUB_TOKEN = previous;
if (previousGh === undefined) delete process.env.GH_TOKEN;
else process.env.GH_TOKEN = previousGh;
if (previousGithub === undefined) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = previousGithub;
if (previousMinimax === undefined) delete process.env.MINIMAX_API_KEY;
else process.env.MINIMAX_API_KEY = previousMinimax;
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
else process.env.MOONSHOT_API_KEY = previousMoonshot;
}
});
});
let previousHome: string | undefined; let previousHome: string | undefined;
beforeEach(() => { beforeEach(() => {

View File

@@ -12,16 +12,13 @@ import {
} from "../auto-reply/thinking.js"; } from "../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { formatSandboxToolPolicyBlockedMessage } from "./sandbox.js"; import { formatSandboxToolPolicyBlockedMessage } from "./sandbox.js";
import { repairToolUseResultPairing } from "./session-transcript-repair.js";
import { import {
isValidCloudCodeAssistToolId, isValidCloudCodeAssistToolId,
sanitizeToolCallId, sanitizeToolCallId,
sanitizeToolCallIdsForCloudCodeAssist, sanitizeToolCallIdsForCloudCodeAssist,
} from "./tool-call-id.js"; } from "./tool-call-id.js";
import { sanitizeContentBlocksImages } from "./tool-images.js"; import { sanitizeContentBlocksImages } from "./tool-images.js";
import {
repairToolUseResultPairing,
sanitizeToolUseResultPairing,
} from "./session-transcript-repair.js";
import type { WorkspaceBootstrapFile } from "./workspace.js"; import type { WorkspaceBootstrapFile } from "./workspace.js";
export type EmbeddedContextFile = { path: string; content: string }; export type EmbeddedContextFile = { path: string; content: string };

View File

@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { SessionManager } from "@mariozechner/pi-coding-agent"; import { SessionManager } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js"; import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js"; import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js";
@@ -34,9 +33,10 @@ describe("guardSessionManager integration", () => {
"assistant", "assistant",
]); ]);
expect((messages[1] as { toolCallId?: string }).toolCallId).toBe("call_1"); expect((messages[1] as { toolCallId?: string }).toolCallId).toBe("call_1");
expect( expect(sanitizeToolUseResultPairing(messages).map((m) => m.role)).toEqual([
sanitizeToolUseResultPairing(messages).map((m) => m.role), "assistant",
).toEqual(["assistant", "toolResult", "assistant"]); "toolResult",
"assistant",
]);
}); });
}); });

View File

@@ -117,11 +117,8 @@ import { makeToolPrunablePredicate } from "./pi-extensions/context-pruning/tools
import { toToolDefinitions } from "./pi-tool-definition-adapter.js"; import { toToolDefinitions } from "./pi-tool-definition-adapter.js";
import { createClawdbotCodingTools } from "./pi-tools.js"; import { createClawdbotCodingTools } from "./pi-tools.js";
import { resolveSandboxContext } from "./sandbox.js"; import { resolveSandboxContext } from "./sandbox.js";
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js"; import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js";
import {
guardSessionManager,
type GuardedSessionManager,
} from "./session-tool-result-guard-wrapper.js";
import { import {
applySkillEnvOverrides, applySkillEnvOverrides,
applySkillEnvOverridesFromSnapshot, applySkillEnvOverridesFromSnapshot,
@@ -1672,8 +1669,7 @@ export async function runEmbeddedPiAgent(params: {
model, model,
}); });
const toolResultGuard = const toolResultGuard = guardSessionManager(sessionManager);
installSessionToolResultGuard(sessionManager);
const { builtInTools, customTools } = splitSdkTools({ const { builtInTools, customTools } = splitSdkTools({
tools, tools,
@@ -1727,7 +1723,7 @@ export async function runEmbeddedPiAgent(params: {
session.agent.replaceMessages(limited); session.agent.replaceMessages(limited);
} }
} catch (err) { } catch (err) {
toolResultGuard.flushPendingToolResults(); toolResultGuard.flushPendingToolResults?.();
session.dispose(); session.dispose();
await sessionLock.release(); await sessionLock.release();
throw err; throw err;
@@ -1759,7 +1755,7 @@ export async function runEmbeddedPiAgent(params: {
enforceFinalTag: params.enforceFinalTag, enforceFinalTag: params.enforceFinalTag,
}); });
} catch (err) { } catch (err) {
toolResultGuard.flushPendingToolResults(); toolResultGuard.flushPendingToolResults?.();
session.dispose(); session.dispose();
await sessionLock.release(); await sessionLock.release();
throw err; throw err;
@@ -1857,7 +1853,7 @@ export async function runEmbeddedPiAgent(params: {
ACTIVE_EMBEDDED_RUNS.delete(params.sessionId); ACTIVE_EMBEDDED_RUNS.delete(params.sessionId);
notifyEmbeddedRunEnded(params.sessionId); notifyEmbeddedRunEnded(params.sessionId);
} }
sessionManager.flushPendingToolResults?.(); toolResultGuard.flushPendingToolResults?.();
session.dispose(); session.dispose();
await sessionLock.release(); await sessionLock.release();
params.abortSignal?.removeEventListener?.("abort", onAbort); params.abortSignal?.removeEventListener?.("abort", onAbort);

View File

@@ -14,10 +14,7 @@ import type {
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
import { isGoogleModelApi } from "../pi-embedded-helpers.js"; import { isGoogleModelApi } from "../pi-embedded-helpers.js";
import { import { repairToolUseResultPairing } from "../session-transcript-repair.js";
repairToolUseResultPairing,
sanitizeToolUseResultPairing,
} from "../session-transcript-repair.js";
import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js"; import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js";
export default function transcriptSanitizeExtension(api: ExtensionAPI): void { export default function transcriptSanitizeExtension(api: ExtensionAPI): void {

View File

@@ -14,7 +14,10 @@ export type GuardedSessionManager = SessionManager & {
export function guardSessionManager( export function guardSessionManager(
sessionManager: SessionManager, sessionManager: SessionManager,
): GuardedSessionManager { ): GuardedSessionManager {
if (typeof (sessionManager as GuardedSessionManager).flushPendingToolResults === "function") { if (
typeof (sessionManager as GuardedSessionManager).flushPendingToolResults ===
"function"
) {
return sessionManager as GuardedSessionManager; return sessionManager as GuardedSessionManager;
} }
@@ -23,4 +26,3 @@ export function guardSessionManager(
guard.flushPendingToolResults; guard.flushPendingToolResults;
return sessionManager as GuardedSessionManager; return sessionManager as GuardedSessionManager;
} }

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { SessionManager } from "@mariozechner/pi-coding-agent"; import { SessionManager } from "@mariozechner/pi-coding-agent";
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { describe, expect, it } from "vitest";
import { installSessionToolResultGuard } from "./session-tool-result-guard.js"; import { installSessionToolResultGuard } from "./session-tool-result-guard.js";
@@ -110,9 +110,7 @@ describe("installSessionToolResultGuard", () => {
"toolResult", // synthetic for call_b "toolResult", // synthetic for call_b
"assistant", // text "assistant", // text
]); ]);
expect( expect((messages[2] as { toolCallId?: string }).toolCallId).toBe("call_b");
(messages[2] as { toolCallId?: string }).toolCallId,
).toBe("call_b");
expect(guard.getPendingIds()).toEqual([]); expect(guard.getPendingIds()).toEqual([]);
}); });

View File

@@ -94,7 +94,8 @@ export function installSessionToolResultGuard(sessionManager: SessionManager): {
}; };
// Monkey-patch appendMessage with our guarded version. // Monkey-patch appendMessage with our guarded version.
sessionManager.appendMessage = guardedAppend as SessionManager["appendMessage"]; sessionManager.appendMessage =
guardedAppend as SessionManager["appendMessage"];
return { return {
flushPendingToolResults, flushPendingToolResults,