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
2 changes: 2 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4876,6 +4876,8 @@ describe("adeRpcServer", () => {
failingCheckCount: 0,
pendingCheckCount: 0,
actionableReviewThreadCount: 1,
actionableIssueCommentCount: 1,
actionableCommentCount: 2,
hasActionableChecks: false,
hasActionableComments: true,
}),
Expand Down
21 changes: 7 additions & 14 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ import { runGit } from "../../desktop/src/main/services/git/git";
import { resolvePathWithinRoot } from "../../desktop/src/main/services/shared/utils";
import { getDefaultModelDescriptor } from "../../desktop/src/shared/modelRegistry";
import { ADE_CLI_INLINE_GUIDANCE } from "../../desktop/src/shared/adeCliGuidance";
import { getPrIssueResolutionAvailability } from "../../desktop/src/shared/prIssueResolution";
import {
getPrIssueResolutionAvailability,
isActionablePrIssueComment,
} from "../../desktop/src/shared/prIssueResolution";
import {
type LinearWorkflowConfig,
type ComputerUseArtifactOwner,
Expand Down Expand Up @@ -2694,11 +2697,6 @@ function requirePrService(runtime: AdeRuntime): NonNullable<AdeRuntime["prServic
return runtime.prService;
}

function isBotAuthor(author: string): boolean {
const normalized = author.trim().toLowerCase();
return normalized.includes("[bot]") || normalized.includes("github-actions");
}

function summarizePrChecks(checks: PrCheck[]): { overall: "failing" | "pending" | "passing"; counts: { passing: number; failing: number; pending: number; total: number } } {
const passing = checks.filter((check) => check.conclusion === "success").length;
const failing = checks.filter((check) => check.conclusion === "failure").length;
Expand All @@ -2722,12 +2720,7 @@ function summarizePrReviewComments(
checks: PrCheck[],
reviewThreads: PrReviewThread[],
) {
// Issue comments: still skip bot chatter (e.g. CI status echoes). Inline review-thread
// comments are NOT filtered here — they come from review bots like Greptile/CodeRabbit,
// which are exactly the actionable signal the agent needs to address.
const actionableIssueComments = comments.filter(
(comment) => Boolean(comment.body?.trim()) && comment.source === "issue" && !isBotAuthor(comment.author),
);
const actionableIssueComments = comments.filter(isActionablePrIssueComment);
const unresolvedThreads = reviewThreads.filter((thread) => !thread.isResolved && !thread.isOutdated);
const actionableThreadCommentCount = unresolvedThreads.reduce((acc, thread) => acc + thread.comments.length, 0);
const pendingReviews = reviews.filter((review) => review.state === "changes_requested" || review.state === "commented");
Expand Down Expand Up @@ -2782,7 +2775,7 @@ function summarizePrIssueInventory(args: {
reviewThreads: PrReviewThread[];
comments: PrComment[];
}) {
const availability = getPrIssueResolutionAvailability(args.checks, args.reviewThreads);
const availability = getPrIssueResolutionAvailability(args.checks, args.reviewThreads, args.comments);
const failingRuns = args.actionRuns
.filter((run) => run.conclusion === "failure" || run.conclusion === "timed_out" || run.conclusion === "action_required")
.map((run) => ({
Expand Down Expand Up @@ -2824,7 +2817,7 @@ function summarizePrIssueInventory(args: {
})),
})),
issueComments: args.comments
.filter((comment) => comment.source === "issue")
.filter(isActionablePrIssueComment)
.map((comment) => ({
id: comment.id,
author: comment.author,
Expand Down
11 changes: 11 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ describe("commands", () => {
expect(parsed ? commandPlacement(parsed) : null).toBe("right");
});

it("routes PR comments to the right pane", () => {
const parsed = parseCommand("/pr comments");
expect(parsed?.name).toBe("/pr comments");
expect(parsed ? commandPlacement(parsed) : null).toBe("right");
expect(paletteCommands("/pr comm")).toContainEqual(expect.objectContaining({
name: "/pr comments",
source: "ade",
description: "Show actionable PR comments",
}));
});

it("routes /feedback to the ADE Code right pane", () => {
const parsed = parseCommand("/feedback");
expect(parsed?.spec?.name).toBe("/feedback");
Expand Down
11 changes: 7 additions & 4 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4222,10 +4222,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }
}
const pr = name === "/pr checks"
? await conn.actionList("pr", "getChecks", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) }))
: await Promise.all([
conn.actionList("pr", "getReviews", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })),
conn.actionList("pr", "getReviewThreads", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })),
]).then(([reviews, threads]) => ({ reviews, threads }));
: name === "/pr comments"
? await conn.tool("pr_get_review_comments", { prId }).catch((err) => ({ error: err instanceof Error ? err.message : String(err) }))
: await Promise.all([
conn.actionList("pr", "getReviews", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })),
conn.actionList("pr", "getReviewThreads", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })),
conn.actionList("pr", "getComments", [prId]).catch((err) => ({ error: err instanceof Error ? err.message : String(err) })),
]).then(([reviews, threads, comments]) => ({ reviews, threads, comments }));
setRightPane({ kind: "details", title: name.slice(1), body: renderObject(pr, 24) });
return;
}
Expand Down
1 change: 1 addition & 0 deletions apps/ade-cli/src/tuiClient/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [
{ name: "/pr", description: "Show pull request state", placement: "right" },
{ name: "/pr open", description: "Create or open a PR for the active lane", placement: "right" },
{ name: "/pr review", description: "Show PR reviews", placement: "right" },
{ name: "/pr comments", description: "Show actionable PR comments", placement: "right" },
{ name: "/pr checks", description: "Show PR checks", placement: "right" },
{ name: "/linear", description: "Run Linear workflow, route, sync, or ingress commands", placement: "right", argumentHint: "<group>" },
{ name: "/linear list", description: "List Linear work", placement: "right" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ describe("createWorkflowTools", () => {
hasActionableComments: true,
failingCheckCount: 1,
actionableReviewThreadCount: 1,
actionableIssueCommentCount: 1,
actionableCommentCount: 2,
});
expect(result.reviewThreads).toHaveLength(1);
expect(result.failingWorkflowRuns[0]).toMatchObject({ name: "CI" });
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/services/ai/tools/workflowTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ export function createWorkflowTools(
prService.getReviewThreads(prId),
prService.getComments(prId),
]);
const availability = getPrIssueResolutionAvailability(checks, reviewThreads);
const availability = getPrIssueResolutionAvailability(checks, reviewThreads, comments);
const failingRuns = actionRuns
.filter((run) => run.conclusion === "failure" || run.conclusion === "timed_out" || run.conclusion === "action_required")
.map((run) => ({
Expand Down
81 changes: 53 additions & 28 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3579,7 +3579,9 @@ describe("createAgentChatService", () => {
});

it("emits a rate-limit notice when the Claude SDK reports usage pressure", async () => {
vi.useFakeTimers();
const send = vi.fn().mockResolvedValue(undefined);
const close = vi.fn();
let streamCall = 0;
const stream = vi.fn(() => (async function* () {
streamCall += 1;
Expand Down Expand Up @@ -3608,41 +3610,64 @@ describe("createAgentChatService", () => {
vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({
send,
stream,
close: vi.fn(),
close,
sessionId: "sdk-session-rate-limit",
} as any);
vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue({
send,
stream,
close,
sessionId: "sdk-session-rate-limit",
} as any);

const onEvent = vi.fn();
const { service } = createService({ onEvent });
const session = await service.createSession({
laneId: "lane-1",
provider: "claude",
model: "sonnet",
});
try {
const session = await service.createSession({
laneId: "lane-1",
provider: "claude",
model: "sonnet",
});

await service.runSessionTurn({
sessionId: session.id,
text: "show usage pressure",
timeoutMs: 15_000,
});
await new Promise((resolve) => setTimeout(resolve, 25));
await service.runSessionTurn({
sessionId: session.id,
text: "show usage pressure",
timeoutMs: 15_000,
});

const rateLimitNotices = onEvent.mock.calls
.map((call) => call[0])
.filter((env: any) => env?.event?.type === "system_notice" && env.event.noticeKind === "rate_limit");
expect(rateLimitNotices).toHaveLength(1);
expect(rateLimitNotices[0].event).toMatchObject({
type: "system_notice",
noticeKind: "rate_limit",
severity: "warning",
status: "allowed_warning",
message: "Claude rate limit allowed warning",
});
expect(rateLimitNotices[0].event.detail).toContain("82% utilized");
expect(rateLimitNotices[0].event.detail).toContain("resets");
expect(claudeSdkCreateSessionCompat.mock.calls.some(([options]) =>
options?.pathToClaudeCodeExecutable === "/usr/local/bin/claude",
)).toBe(true);
let rateLimitNotices = onEvent.mock.calls
.map((call) => call[0])
.filter((env: any) => env?.event?.type === "system_notice" && env.event.noticeKind === "rate_limit");
expect(rateLimitNotices).toHaveLength(1);
expect(rateLimitNotices[0].event).toMatchObject({
type: "system_notice",
noticeKind: "rate_limit",
severity: "info",
status: "allowed_warning",
message: "Approaching Claude plan limit",
});
expect(rateLimitNotices[0].event.detail).toContain("82% utilized");
expect(rateLimitNotices[0].event.detail).toContain("resets");

await vi.advanceTimersByTimeAsync(6 * 60_000);
expect(close).toHaveBeenCalledTimes(1);

await service.runSessionTurn({
sessionId: session.id,
text: "show usage pressure again",
timeoutMs: 15_000,
});

rateLimitNotices = onEvent.mock.calls
.map((call) => call[0])
.filter((env: any) => env?.event?.type === "system_notice" && env.event.noticeKind === "rate_limit");
expect(rateLimitNotices).toHaveLength(1);
expect(claudeSdkCreateSessionCompat.mock.calls.some(([options]) =>
options?.pathToClaudeCodeExecutable === "/usr/local/bin/claude",
)).toBe(true);
} finally {
vi.useRealTimers();
}
});

it("registers a PreCompact hook on non-lightweight Claude sessions", async () => {
Expand Down
30 changes: 22 additions & 8 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,8 @@ type ClaudeRuntime = {
pauseIdleWatchdog?: (() => void) | null;
/** Resume the active-turn idle watchdog after the blocking wait finishes. */
resumeIdleWatchdog?: (() => void) | null;
/** Set after we've emitted the once-per-session "Approaching plan limit" notice. */
rateLimitWarningEmitted: boolean;
};

const CODEX_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [
Expand Down Expand Up @@ -1451,6 +1453,8 @@ type ManagedChatSession = {
selectedExecutionLaneId: string | null;
lastLaneDirectiveKey: string | null;
runtimeInvalidated: boolean;
/** Set after we've emitted the once-per-session Claude plan-limit notice. */
claudeRateLimitWarningEmitted: boolean;
codexTerminalTurnIds: Set<string>;
todoItems: Extract<AgentChatEvent, { type: "todo_update" }>["items"];
localPendingInputs: Map<string, {
Expand Down Expand Up @@ -8294,6 +8298,7 @@ export function createAgentChatService(args: {
selectedExecutionLaneId: persisted?.selectedExecutionLaneId ?? null,
lastLaneDirectiveKey: persisted?.lastLaneDirectiveKey ?? null,
runtimeInvalidated: false,
claudeRateLimitWarningEmitted: false,
codexTerminalTurnIds: new Set<string>(persisted?.codexTerminalTurnIds ?? []),
todoItems: [],
activeAssistantMessageId: null,
Expand Down Expand Up @@ -9414,13 +9419,16 @@ export function createAgentChatService(args: {
const rateMsg = msg as any;
const info = rateMsg.rate_limit_info ?? {};
const rawStatus = typeof info.status === "string" ? info.status : "updated";
const status = rawStatus.replace(/_/g, " ");
const severity: "info" | "warning" | "error" =
rawStatus === "allowed"
? "info"
: rawStatus === "allowed_warning"
? "warning"
: "error";
const isError = rawStatus !== "allowed" && rawStatus !== "allowed_warning";
// "allowed" = under threshold (no signal needed). "allowed_warning" = approaching limit;
// surface as an informational once-per-session notice. Anything else = real failure.
if (rawStatus === "allowed") continue;
if (rawStatus === "allowed_warning" && managed.claudeRateLimitWarningEmitted) continue;
if (rawStatus === "allowed_warning") {
managed.claudeRateLimitWarningEmitted = true;
runtime.rateLimitWarningEmitted = true;
}
const severity: "info" | "warning" | "error" = isError ? "error" : "info";
const details: string[] = [];
if (typeof info.utilization === "number") {
const percent = info.utilization <= 1
Expand All @@ -9433,12 +9441,15 @@ export function createAgentChatService(args: {
const resetDate = new Date(resetMs);
if (!Number.isNaN(resetDate.getTime())) details.push(`resets ${resetDate.toISOString()}`);
}
const message = isError
? `Claude rate limit ${rawStatus.replace(/_/g, " ")}`
: "Approaching Claude plan limit";
emitChatEvent(managed, {
type: "system_notice",
noticeKind: "rate_limit",
severity,
status: rawStatus,
message: `Claude rate limit ${status}`,
message,
detail: details.length ? details.join(" | ") : undefined,
turnId,
});
Expand Down Expand Up @@ -14298,6 +14309,7 @@ export function createAgentChatService(args: {
turnMemoryPolicyState: null,
approvalOverrides: new Set<string>(persisted?.approvalOverrides ?? []),
resolvedToolUseIds: new Set<string>(),
rateLimitWarningEmitted: managed.claudeRateLimitWarningEmitted,
};
managed.runtime = runtime;
managed.runtimeInvalidated = false;
Expand Down Expand Up @@ -14345,6 +14357,7 @@ export function createAgentChatService(args: {
selectedExecutionLaneId: null,
lastLaneDirectiveKey: null,
runtimeInvalidated: false,
claudeRateLimitWarningEmitted: false,
codexTerminalTurnIds: new Set<string>(),
todoItems: [],
activeAssistantMessageId: null,
Expand Down Expand Up @@ -14790,6 +14803,7 @@ export function createAgentChatService(args: {
selectedExecutionLaneId: null,
lastLaneDirectiveKey: null,
runtimeInvalidated: false,
claudeRateLimitWarningEmitted: false,
codexTerminalTurnIds: new Set<string>(),
todoItems: [],
activeAssistantMessageId: null,
Expand Down
Loading
Loading