Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ import {
} from "../../../shared/types";
import type {
AgentChatApprovalDecision,
AgentChatCancelSteerArgs,
AgentChatClaudePermissionMode,
AgentChatCompletionReport,
AgentChatCodexApprovalPolicy,
AgentChatCodexConfigSource,
AgentChatCodexSandbox,
AgentChatCreateArgs,
AgentChatDisposeArgs,
AgentChatEditSteerArgs,
AgentChatExecutionMode,
AgentChatEvent,
AgentChatEventEnvelope,
Expand Down Expand Up @@ -8355,6 +8357,14 @@ export function createAgentChatService(args: {
await executePreparedSendMessage(preparedSteer);
};

const cancelSteer = async ({ sessionId }: AgentChatCancelSteerArgs): Promise<void> => {
await interrupt({ sessionId });
};

const editSteer = async ({ sessionId, text }: AgentChatEditSteerArgs): Promise<void> => {
await steer({ sessionId, text });
};

const interrupt = async ({ sessionId }: AgentChatInterruptArgs): Promise<void> => {
const managed = ensureManagedSession(sessionId);

Expand Down Expand Up @@ -8954,7 +8964,7 @@ export function createAgentChatService(args: {
}

const nextProvider: AgentChatProvider = resolveProviderGroupForModel(descriptor);
const nextModel = descriptor.isCliWrapped ? descriptor.shortId : descriptor.id;
const nextModel = descriptor.isCliWrapped ? descriptor.sdkModelId : descriptor.id;
const previousModelId = managed.session.modelId
?? resolveModelIdFromStoredValue(managed.session.model, managed.session.provider)
?? managed.session.model;
Expand Down Expand Up @@ -9349,6 +9359,8 @@ export function createAgentChatService(args: {
sendMessage,
runSessionTurn,
steer,
cancelSteer,
editSteer,
interrupt,
resumeSession,
listSessions,
Expand Down
67 changes: 67 additions & 0 deletions apps/desktop/src/main/services/git/gitOperationsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,73 @@ vi.mock("./git", () => ({

import { createGitOperationsService } from "./gitOperationsService";

describe("gitOperationsService.stashClear", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("calls git stash clear with the lane worktree path and returns the action result", async () => {
mockGit.getHeadSha.mockResolvedValue("abc123");
mockGit.runGitOrThrow.mockResolvedValue(undefined);

const mockStart = vi.fn().mockReturnValue({ operationId: "op-1" });
const mockFinish = vi.fn();

const service = createGitOperationsService({
laneService: {
getLaneBaseAndBranch: vi.fn().mockReturnValue({
baseRef: "main",
branchRef: "feature/stash-test",
worktreePath: "/tmp/ade-lane",
laneType: "worktree",
}),
} as any,
operationService: {
start: mockStart,
finish: mockFinish,
} as any,
projectConfigService: {
get: () => ({ effective: { ai: {} } }),
} as any,
aiIntegrationService: {
getFeatureFlag: () => false,
getStatus: vi.fn(async () => ({ availableModelIds: [] })),
generateCommitMessage: vi.fn(),
} as any,
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as any,
});

const result = await service.stashClear({ laneId: "lane-1" });

expect(mockGit.runGitOrThrow).toHaveBeenCalledWith(
["stash", "clear"],
{ cwd: "/tmp/ade-lane", timeoutMs: 15_000 },
);
expect(result).toEqual({
operationId: "op-1",
preHeadSha: "abc123",
postHeadSha: "abc123",
});
expect(mockStart).toHaveBeenCalledWith(
expect.objectContaining({
laneId: "lane-1",
kind: "git_stash_clear",
}),
);
expect(mockFinish).toHaveBeenCalledWith(
expect.objectContaining({
operationId: "op-1",
status: "succeeded",
}),
);
});
});

describe("gitOperationsService.generateCommitMessage", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/main/services/git/gitOperationsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,19 @@ export function createGitOperationsService({
return action;
},

async stashClear(args: { laneId: string }): Promise<GitActionResult> {
const { action } = await runLaneOperation({
laneId: args.laneId,
kind: "git_stash_clear",
reason: "stash_clear",
metadata: {},
fn: async (lane) => {
await runGitOrThrow(["stash", "clear"], { cwd: lane.worktreePath, timeoutMs: 15_000 });
}
});
return action;
},

async fetch(args: { laneId: string }): Promise<GitActionResult> {
const { action } = await runLaneOperation({
laneId: args.laneId,
Expand Down
209 changes: 209 additions & 0 deletions apps/desktop/src/main/services/github/githubService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

// ---------------------------------------------------------------------------
// vi.hoisted mock state
// ---------------------------------------------------------------------------
const mockFetch = vi.hoisted(() => vi.fn());

// ---------------------------------------------------------------------------
// vi.mock — external dependencies
// ---------------------------------------------------------------------------

vi.mock("electron", () => ({
safeStorage: {
isEncryptionAvailable: () => true,
decryptString: () => JSON.stringify({ token: "ghp_mock" }),
encryptString: (s: string) => Buffer.from(s),
},
}));

vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
default: {
...actual,
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => Buffer.from("encrypted")),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
chmodSync: vi.fn(),
unlinkSync: vi.fn(),
},
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => Buffer.from("encrypted")),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
chmodSync: vi.fn(),
unlinkSync: vi.fn(),
};
});

vi.mock("../git/git", () => ({
runGit: vi.fn(),
}));

// Replace global fetch
vi.stubGlobal("fetch", mockFetch);

import { createGithubService } from "./githubService";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeLogger() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as any;
}

function makeService() {
return createGithubService({
logger: makeLogger(),
projectRoot: "/tmp/test-project",
appDataDir: "/tmp/test-appdata",
});
}

function jsonResponse(
status: number,
body: unknown,
extraHeaders?: Record<string, string>,
) {
const headers = new Headers({ "content-type": "application/json", ...extraHeaders });
return {
ok: status >= 200 && status < 300,
status,
headers,
text: async () => JSON.stringify(body),
json: async () => body,
} as unknown as Response;
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("githubService.apiRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns data and response on success (HTTP 200)", async () => {
const payload = { id: 1, name: "test-repo" };
mockFetch.mockResolvedValueOnce(jsonResponse(200, payload));

const service = makeService();
const result = await service.apiRequest({
method: "GET",
path: "/repos/owner/repo",
token: "ghp_test123",
});

expect(result.data).toEqual(payload);
expect(result.response).toBeDefined();
expect(result.response!.status).toBe(200);
});

it("throws with message from response when errors array is absent", async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(404, { message: "Not Found" }));

const service = makeService();
await expect(
service.apiRequest({ method: "GET", path: "/repos/owner/nope", token: "ghp_test123" }),
).rejects.toThrow("Not Found");
});

it("appends single error detail from errors array", async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse(422, {
message: "Validation Failed",
errors: [{ message: "A pull request already exists" }],
}),
);

const service = makeService();
await expect(
service.apiRequest({ method: "POST", path: "/repos/o/r/pulls", token: "ghp_test123" }),
).rejects.toThrow("Validation Failed: A pull request already exists");
});

it("joins multiple error details with semicolons", async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse(422, {
message: "Validation Failed",
errors: [{ message: "err1" }, { message: "err2" }],
}),
);

const service = makeService();
await expect(
service.apiRequest({ method: "POST", path: "/repos/o/r/pulls", token: "ghp_test123" }),
).rejects.toThrow("Validation Failed: err1; err2");
});

it("includes rate limit info and rateLimitResetAtMs when rate-limited", async () => {
const resetTimestamp = Math.floor(Date.now() / 1000) + 3600;
mockFetch.mockResolvedValueOnce(
jsonResponse(
403,
{
message: "API rate limit exceeded",
errors: [{ message: "some detail" }],
},
{
"x-ratelimit-remaining": "0",
"x-ratelimit-reset": String(resetTimestamp),
},
),
);

const service = makeService();
let thrownError: any;
try {
await service.apiRequest({ method: "GET", path: "/repos/o/r", token: "ghp_test123" });
} catch (err) {
thrownError = err;
}

expect(thrownError).toBeInstanceOf(Error);
expect(thrownError.message).toContain("API rate limit exceeded");
expect(thrownError.message).toContain("some detail");
expect(thrownError.message).toContain("rate limit exceeded; resets at");
expect(thrownError.rateLimitResetAtMs).toBe(resetTimestamp * 1000);
});

it("falls back to generic HTTP message when response body has no message field", async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(500, { unexpected: true }));

const service = makeService();
await expect(
service.apiRequest({ method: "GET", path: "/test", token: "ghp_test123" }),
).rejects.toThrow("GitHub API request failed (HTTP 500)");
});

it("ignores errors array entries without a string message", async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse(422, {
message: "Validation Failed",
errors: [{ code: "custom" }, { message: "real error" }, { message: 42 }],
}),
);

const service = makeService();
await expect(
service.apiRequest({ method: "POST", path: "/repos/o/r/pulls", token: "ghp_test123" }),
).rejects.toThrow("Validation Failed: real error");
});

it("throws when no token is provided and none is stored", async () => {
const service = makeService();
await expect(
service.apiRequest({ method: "GET", path: "/test" }),
).rejects.toThrow("GitHub token missing");
});
});
18 changes: 13 additions & 5 deletions apps/desktop/src/main/services/github/githubService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,20 +255,28 @@ export function createGithubService({
}

if (!response.ok) {
const message =
(data && typeof data === "object" && !Array.isArray(data) ? asString((data as any).message) : "") ||
`GitHub API request failed (HTTP ${response.status})`;
const body = data && typeof data === "object" && !Array.isArray(data) ? (data as Record<string, unknown>) : null;
const message = (body ? asString(body.message) : "") || `GitHub API request failed (HTTP ${response.status})`;
let detail = "";
if (body && Array.isArray(body.errors)) {
const errorMessages = (body.errors as any[])
.map((e) => (typeof e === "object" && e && typeof e.message === "string" ? e.message : null))
.filter(Boolean);
if (errorMessages.length > 0) {
detail = ": " + errorMessages.join("; ");
}
}
const rateRemaining = response.headers.get("x-ratelimit-remaining");
const rateReset = response.headers.get("x-ratelimit-reset");
if (rateRemaining === "0" && rateReset) {
const resetAtMs = Number(rateReset) * 1000;
const err = new Error(
`${message} (rate limit exceeded; resets at ${new Date(resetAtMs).toLocaleString()})`
`${message}${detail} (rate limit exceeded; resets at ${new Date(resetAtMs).toLocaleString()})`
);
(err as any).rateLimitResetAtMs = resetAtMs;
throw err;
}
throw new Error(message);
throw new Error(message + detail);
}

// Cache ETag for future conditional requests
Expand Down
Loading
Loading