Skip to content
Open
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
85 changes: 85 additions & 0 deletions apps/desktop/src/main/__tests__/agent-queue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { EventEmitter } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const {
runRoadmapGenerationMock,
writeFileWithRetryMock,
} = vi.hoisted(() => ({
runRoadmapGenerationMock: vi.fn(),
writeFileWithRetryMock: vi.fn().mockResolvedValue(undefined),
}));

vi.mock('../ai/runners/roadmap', () => ({
runRoadmapGeneration: runRoadmapGenerationMock,
}));

vi.mock('../utils/atomic-file', () => ({
writeFileWithRetry: writeFileWithRetryMock,
}));

vi.mock('../utils/debounce', () => ({
debounce: (fn: (...args: unknown[]) => unknown) => ({
fn,
cancel: vi.fn(),
}),
}));

import { AgentQueueManager } from '../agent/agent-queue';
import { AgentState } from '../agent/agent-state';

describe('AgentQueueManager roadmap progress mapping', () => {
let projectPath: string;

beforeEach(() => {
vi.clearAllMocks();
projectPath = mkdtempSync(join(tmpdir(), 'agent-queue-'));
});

afterEach(() => {
rmSync(projectPath, { recursive: true, force: true });
});

it('maps runner phase names to frontend roadmap phases', async () => {
runRoadmapGenerationMock.mockImplementation(
async (
_config: unknown,
onStream?: (event: { type: string; phase?: string }) => void,
) => {
onStream?.({ type: 'phase-start', phase: 'discovery' });
onStream?.({ type: 'phase-start', phase: 'features' });

return {
success: false,
phases: [],
error: 'boom',
};
},
);

const emitter = new EventEmitter();
const progressUpdates: Array<{ phase: string; progress: number; message: string }> = [];
emitter.on('roadmap-progress', (_projectId, status) => {
progressUpdates.push(status);
});

const queue = new AgentQueueManager(
new AgentState(),
{} as never,
{ killProcess: vi.fn() } as never,
emitter,
);

await queue.startRoadmapGeneration('project-1', projectPath);

expect(progressUpdates.map((status) => status.phase)).toEqual([
'analyzing',
'discovering',
'generating',
]);
expect(progressUpdates[1]?.message).toContain('discovery');
expect(progressUpdates[2]?.message).toContain('features');
});
});
9 changes: 8 additions & 1 deletion apps/desktop/src/main/agent/agent-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,14 @@ export class AgentQueueManager {
(event: RoadmapStreamEvent) => {
switch (event.type) {
case 'phase-start': {
progressPhase = event.phase;
// Map runner phase names to XState machine state names
// Runner uses: 'discovery', 'features'
// Machine expects: 'discovering', 'generating'
const phaseMap: Record<string, string> = {
'discovery': 'discovering',
'features': 'generating',
};
progressPhase = phaseMap[event.phase] ?? event.phase;
progressPercent = Math.min(progressPercent + 20, 90);
const msg = `Running ${event.phase} phase...`;
this.emitter.emit('roadmap-log', projectId, msg);
Expand Down
39 changes: 33 additions & 6 deletions apps/desktop/src/main/ai/auth/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ type SettingsAccessor = (key: string) => string | undefined;

let _getSettingsValue: SettingsAccessor | null = null;

function throwIfAborted(signal?: AbortSignal): void {
if (!signal?.aborted) return;

const reason = signal.reason;
if (reason instanceof Error) {
throw reason;
}

throw new Error(typeof reason === 'string' ? reason : 'Aborted');
}

/**
* Register a settings accessor function.
* Called once during app initialization to wire up settings access.
Expand All @@ -70,6 +81,8 @@ export function registerSettingsAccessor(accessor: SettingsAccessor): void {
* This is the highest priority stage — checks providerAccounts array.
*/
async function resolveFromProviderAccount(ctx: AuthResolverContext): Promise<ResolvedAuth | null> {
throwIfAborted(ctx.abortSignal);

if (!_getSettingsValue) return null;

// Read providerAccounts from settings
Expand All @@ -95,7 +108,7 @@ async function resolveFromProviderAccount(ctx: AuthResolverContext): Promise<Res
const { app } = await import('electron');
const tokenFilePath = path.join(app.getPath('userData'), 'codex-auth.json');
const { ensureValidOAuthToken } = await import('../providers/oauth-fetch');
const token = await ensureValidOAuthToken(tokenFilePath, 'openai');
const token = await ensureValidOAuthToken(tokenFilePath, 'openai', ctx.abortSignal);
if (token) {
return {
apiKey: 'codex-oauth-placeholder', // Dummy key; real token injected via custom fetch
Expand Down Expand Up @@ -145,7 +158,7 @@ async function resolveFromProfileOAuth(ctx: AuthResolverContext): Promise<Resolv
if (ctx.provider !== 'anthropic') return null;

try {
const tokenResult = await ensureValidToken(ctx.configDir);
const tokenResult = await ensureValidToken(ctx.configDir, undefined, ctx.abortSignal);
if (tokenResult.token) {
const resolved: ResolvedAuth = {
apiKey: tokenResult.token,
Expand All @@ -164,6 +177,7 @@ async function resolveFromProfileOAuth(ctx: AuthResolverContext): Promise<Resolv
return resolved;
}
} catch {
throwIfAborted(ctx.abortSignal);
// Token refresh failed (network, keychain locked, etc.) — fall through
}

Expand Down Expand Up @@ -291,6 +305,8 @@ function resolveDefaultCredentials(ctx: AuthResolverContext): ResolvedAuth | nul
* @returns Resolved auth credentials, or null if no credentials found
*/
export async function resolveAuth(ctx: AuthResolverContext): Promise<ResolvedAuth | null> {
throwIfAborted(ctx.abortSignal);

return (
(await resolveFromProviderAccount(ctx)) ??
(await resolveFromProfileOAuth(ctx)) ??
Expand Down Expand Up @@ -352,8 +368,11 @@ export async function resolveAuthFromQueue(
excludeAccountIds?: string[];
userModelOverrides?: Record<string, Partial<Record<BuiltinProvider, import('../../../shared/constants/models').ProviderModelSpec>>>;
autoSwitchSettings?: ClaudeAutoSwitchSettings;
abortSignal?: AbortSignal;
}
): Promise<QueueResolvedAuth | null> {
throwIfAborted(options?.abortSignal);

const excludeSet = new Set(options?.excludeAccountIds ?? []);
const defaultSettings: ClaudeAutoSwitchSettings = {
enabled: true,
Expand All @@ -367,6 +386,8 @@ export async function resolveAuthFromQueue(
const settings = options?.autoSwitchSettings ?? defaultSettings;

for (const account of queue) {
throwIfAborted(options?.abortSignal);

// Skip excluded accounts
if (excludeSet.has(account.id)) continue;

Expand Down Expand Up @@ -411,7 +432,7 @@ export async function resolveAuthFromQueue(
// is needed here. All OpenAI models are eligible through Codex OAuth.

// Resolve credentials for this account
const auth = await resolveCredentialsForAccount(account, supportedProvider);
const auth = await resolveCredentialsForAccount(account, supportedProvider, options?.abortSignal);
if (!auth) continue;

// Success — return the fully resolved auth
Expand Down Expand Up @@ -495,7 +516,10 @@ function resolveZaiBaseUrl(account: ProviderAccount): string {
async function resolveCredentialsForAccount(
account: ProviderAccount,
provider: SupportedProvider,
abortSignal?: AbortSignal,
): Promise<ResolvedAuth | null> {
throwIfAborted(abortSignal);

// No-auth providers (e.g., Ollama) — no API key required
if (NO_AUTH_PROVIDERS.has(provider)) {
return {
Expand All @@ -511,23 +535,26 @@ async function resolveCredentialsForAccount(
const { app } = await import('electron');
const tokenFilePath = path.join(app.getPath('userData'), 'codex-auth.json');
const { ensureValidOAuthToken } = await import('../providers/oauth-fetch');
const token = await ensureValidOAuthToken(tokenFilePath, 'openai');
const token = await ensureValidOAuthToken(tokenFilePath, 'openai', abortSignal);
if (token) {
return {
apiKey: 'codex-oauth-placeholder',
source: 'codex-oauth',
oauthTokenFilePath: tokenFilePath,
};
}
} catch { /* fall through */ }
} catch {
throwIfAborted(abortSignal);
/* fall through */
}
return null;
}

// Anthropic OAuth — refresh token via existing claude-profile system
if (account.authType === 'oauth' && account.provider === 'anthropic') {
if (account.claudeProfileId) {
// Delegate to profile OAuth resolution
const ctx: AuthResolverContext = { provider, profileId: account.claudeProfileId };
const ctx: AuthResolverContext = { provider, profileId: account.claudeProfileId, abortSignal };
return resolveAuth(ctx);
}
return null;
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/ai/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export interface AuthResolverContext {
profileId?: string;
/** Optional CLAUDE_CONFIG_DIR for profile-specific keychain lookup */
configDir?: string;
/** Abort signal for cancellation during credential resolution */
abortSignal?: AbortSignal;
Comment on lines +62 to +63
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify resolveAuth call sites and whether abortSignal is forwarded.
# Expected: call paths that can be canceled should thread abortSignal into resolveAuth context.

rg -nP --type=ts '\bresolveAuth\s*\(' apps/desktop/src/main -C 3
rg -nP --type=ts '\bresolveAuthFromQueue\s*\(' apps/desktop/src/main -C 3
rg -nP --type=ts 'abortSignal\s*:' apps/desktop/src/main/ai/auth -C 2

Repository: AndyMik90/Aperant

Length of output: 24470


🏁 Script executed:

# Check the AuthResolverContext type definition to confirm abortSignal is optional
head -100 apps/desktop/src/main/ai/auth/types.ts | tail -50

Repository: AndyMik90/Aperant

Length of output: 1792


🏁 Script executed:

# Get more context around agent-manager.ts line 193
sed -n '180,210p' apps/desktop/src/main/agent/agent-manager.ts

Repository: AndyMik90/Aperant

Length of output: 1375


🏁 Script executed:

# Check resolveAuthFromQueue signature and how it handles abortSignal
sed -n '364,420p' apps/desktop/src/main/ai/auth/resolver.ts

Repository: AndyMik90/Aperant

Length of output: 2430


🏁 Script executed:

# List all callers of resolveAuth to identify which ones pass abortSignal
rg -nP 'resolveAuth\s*\(' apps/desktop/src/main -A 2 | grep -E '(resolveAuth|abortSignal)' | head -40

Repository: AndyMik90/Aperant

Length of output: 3200


🏁 Script executed:

# Check hasCredentials signature to see if it threads abortSignal
sed -n '320,335p' apps/desktop/src/main/ai/auth/resolver.ts

Repository: AndyMik90/Aperant

Length of output: 579


🏁 Script executed:

# Find where getResolvedAuth is called in agent-manager
rg -n 'getResolvedAuth\|resolveAuth' apps/desktop/src/main/agent/agent-manager.ts -B 2 -A 2

Repository: AndyMik90/Aperant

Length of output: 43


🏁 Script executed:

# Find where resolveAuthFromQueue calls resolveAuth internally
sed -n '364,500p' apps/desktop/src/main/ai/auth/resolver.ts | grep -A 20 -B 5 'resolveAuth'

Repository: AndyMik90/Aperant

Length of output: 829


🏁 Script executed:

# Check factory.ts full context around the resolveAuth calls at lines 135 and 276
sed -n '125,145p' apps/desktop/src/main/ai/client/factory.ts
sed -n '265,285p' apps/desktop/src/main/ai/client/factory.ts

Repository: AndyMik90/Aperant

Length of output: 1439


🏁 Script executed:

# Search for where getResolvedAuth is called in agent-manager
rg -n 'getResolvedAuth' apps/desktop/src/main/agent/agent-manager.ts -B 5 -A 5

Repository: AndyMik90/Aperant

Length of output: 43


🏁 Script executed:

# Check the full resolveAuthFromQueue function to find where resolveAuth is called
sed -n '364,650p' apps/desktop/src/main/ai/auth/resolver.ts | head -100

Repository: AndyMik90/Aperant

Length of output: 4084


🏁 Script executed:

# Check agent-manager imports and class definition to find getResolvedAuth signature
head -50 apps/desktop/src/main/agent/agent-manager.ts

Repository: AndyMik90/Aperant

Length of output: 2404


🏁 Script executed:

# Find the method containing line 193 in agent-manager
sed -n '150,200p' apps/desktop/src/main/agent/agent-manager.ts

Repository: AndyMik90/Aperant

Length of output: 2200


🏁 Script executed:

# Search for calls to that method to see if cancellation is needed
rg -n 'getResolvedAuth\|resolveAuthFromQueue' apps/desktop/src/main/agent/agent-manager.ts -B 10 -A 3

Repository: AndyMik90/Aperant

Length of output: 43


🏁 Script executed:

# Let's check line 150-200 more carefully to understand the context
wc -l apps/desktop/src/main/agent/agent-manager.ts
sed -n '140,200p' apps/desktop/src/main/agent/agent-manager.ts

Repository: AndyMik90/Aperant

Length of output: 2667


🏁 Script executed:

# Find the method signature that contains line 193
sed -n '130,145p' apps/desktop/src/main/agent/agent-manager.ts

Repository: AndyMik90/Aperant

Length of output: 598


🏁 Script executed:

# Search for all callers of the getResolvedAuth method
rg -n 'getResolvedAuth' apps/desktop/src/main -A 2 -B 2

Repository: AndyMik90/Aperant

Length of output: 43


🏁 Script executed:

# Check resolveCredentialsForAccount to confirm it threads abortSignal to resolveAuth
rg -n 'resolveCredentialsForAccount' apps/desktop/src/main/ai/auth/resolver.ts -A 20

Repository: AndyMik90/Aperant

Length of output: 1537


🏁 Script executed:

# Find all callers of resolveAuthFromProviderQueue to see if they have abortSignal context
rg -n 'resolveAuthFromProviderQueue' apps/desktop/src/main/agent/agent-manager.ts -B 5 -A 5

Repository: AndyMik90/Aperant

Length of output: 2412


🏁 Script executed:

# Search for abortSignal usage in agent-manager to understand cancellation patterns
rg -n 'abortSignal' apps/desktop/src/main/agent/agent-manager.ts -B 2 -A 2

Repository: AndyMik90/Aperant

Length of output: 43


🏁 Script executed:

# Check the full signature and callers of resolveAuthFromProviderQueue
sed -n '130,210p' apps/desktop/src/main/agent/agent-manager.ts

Repository: AndyMik90/Aperant

Length of output: 3349


🏁 Script executed:

# Check the signatures of the methods that call resolveAuthFromProviderQueue
sed -n '320,380p' apps/desktop/src/main/agent/agent-manager.ts
sed -n '420,480p' apps/desktop/src/main/agent/agent-manager.ts
sed -n '560,600p' apps/desktop/src/main/agent/agent-manager.ts

Repository: AndyMik90/Aperant

Length of output: 8017


🏁 Script executed:

# Search for abortSignal in the broader context of agent-manager tasks
rg -n 'abortSignal|abort|cancel' apps/desktop/src/main/agent/agent-manager.ts | head -20

Repository: AndyMik90/Aperant

Length of output: 43


🏁 Script executed:

# Check if TaskExecutionOptions includes abortSignal
rg -n 'TaskExecutionOptions\|AbortSignal' apps/desktop/src/main/agent/types.ts -A 10 | head -40

Repository: AndyMik90/Aperant

Length of output: 43


🏁 Script executed:

# Verify the full method signatures for the three calling methods
sed -n '310,330p' apps/desktop/src/main/agent/agent-manager.ts
sed -n '441,462p' apps/desktop/src/main/agent/agent-manager.ts
sed -n '550,570p' apps/desktop/src/main/agent/agent-manager.ts

Repository: AndyMik90/Aperant

Length of output: 2791


🏁 Script executed:

# Check if there's an abort/cancel controller at a higher level in agent-manager
rg -n 'AbortController|cancelTask|abortTask|stop' apps/desktop/src/main/agent/agent-manager.ts

Repository: AndyMik90/Aperant

Length of output: 549


🏁 Script executed:

# Verify factory.ts resolveAuth calls include abortSignal
sed -n '130,145p' apps/desktop/src/main/ai/client/factory.ts
sed -n '270,285p' apps/desktop/src/main/ai/client/factory.ts

Repository: AndyMik90/Aperant

Length of output: 1134


Thread abortSignal through resolveAuthFromProviderQueue() to enable cancellation on both queue and fallback auth paths.

resolveAuthFromProviderQueue() in agent-manager.ts (line 135) lacks an abortSignal parameter, so cancellation never reaches the fallback resolveAuth() call at line 193. Meanwhile, factory.ts properly threads abortSignal through resolveAuth() calls. Since startSpecCreation, startTaskExecution, and startQAProcess can be cancelled (via stopIdeation/stopRoadmap), auth resolution should respect cancellation.

Add abortSignal?: AbortSignal to resolveAuthFromProviderQueue() signature and pass it to both resolveAuthFromQueue() (line 176) and resolveAuth() (line 193) to match the pattern already established in factory.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/ai/auth/types.ts` around lines 62 - 63, Add an optional
AbortSignal parameter to resolveAuthFromProviderQueue by changing its signature
to accept abortSignal?: AbortSignal, then forward that abortSignal into both
resolveAuthFromQueue(...) and the fallback resolveAuth(...) calls inside
resolveAuthFromProviderQueue so cancellation propagates to the queue path and
the fallback path; mirror the pattern used in factory.ts where resolveAuth calls
receive the abortSignal. Ensure parameter name matches abortSignal and thread it
through any intermediate calls within resolveAuthFromProviderQueue.

}

// ============================================
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/ai/client/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export async function createAgentClient(
thinkingLevel,
maxSteps = DEFAULT_MAX_STEPS,
profileId,
abortSignal,
additionalMcpServers,
queueConfig,
} = config;
Expand All @@ -101,6 +102,7 @@ export async function createAgentClient(
{
excludeAccountIds: queueConfig.excludeAccountIds,
userModelOverrides: queueConfig.userModelOverrides as any,
abortSignal,
}
);

Expand Down Expand Up @@ -133,6 +135,7 @@ export async function createAgentClient(
const auth = await resolveAuth({
provider: detectedProvider,
profileId,
abortSignal,
});

model = createProvider({
Expand Down Expand Up @@ -216,6 +219,7 @@ export async function createSimpleClient(
modelShorthand = 'haiku',
thinkingLevel = 'low',
profileId,
abortSignal,
maxSteps = DEFAULT_SIMPLE_MAX_STEPS,
tools = {},
queueConfig: explicitQueueConfig,
Expand All @@ -240,6 +244,7 @@ export async function createSimpleClient(
{
excludeAccountIds,
userModelOverrides: userModelOverrides as any,
abortSignal,
}
);

Expand Down Expand Up @@ -271,6 +276,7 @@ export async function createSimpleClient(
const auth = await resolveAuth({
provider: detectedProvider,
profileId,
abortSignal,
});

model = createProvider({
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/ai/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export interface SimpleClientConfig {
thinkingLevel?: ThinkingLevel;
/** Profile ID for credential resolution */
profileId?: string;
/** Abort signal for cancellation during auth/model resolution */
abortSignal?: AbortSignal;
/** Maximum agentic steps (defaults to 1 for single-turn) */
maxSteps?: number;
/** Specific tools to include (if any) */
Expand Down
15 changes: 13 additions & 2 deletions apps/desktop/src/main/ai/providers/oauth-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ async function refreshOAuthToken(
refreshToken: string,
providerSpec: OAuthProviderSpec,
tokenFilePath: string,
abortSignal?: AbortSignal,
): Promise<string | null> {
debugLog('Refreshing OAuth token');

Expand All @@ -118,6 +119,7 @@ async function refreshOAuthToken(
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: abortSignal,
});

debugLog('Token refresh response', { status: response.status, ok: response.ok });
Expand Down Expand Up @@ -186,6 +188,7 @@ function detectProvider(provider?: string): OAuthProviderSpec | undefined {
export async function ensureValidOAuthToken(
tokenFilePath: string,
provider?: string,
abortSignal?: AbortSignal,
): Promise<string | null> {
debugLog('Ensuring valid OAuth token', { path: tokenFilePath, provider });

Expand All @@ -212,8 +215,12 @@ export async function ensureValidOAuthToken(
}

try {
return await refreshOAuthToken(stored.refresh_token, providerSpec, tokenFilePath);
return await refreshOAuthToken(stored.refresh_token, providerSpec, tokenFilePath, abortSignal);
} catch (err) {
if (abortSignal?.aborted) {
throw (abortSignal.reason instanceof Error ? abortSignal.reason : new Error('Aborted'));
}
Comment on lines 216 to +222

This comment was marked as outdated.


debugLog('Token refresh failed', { error: err instanceof Error ? err.message : String(err) });
return null;
}
Expand All @@ -238,7 +245,11 @@ export function createOAuthProviderFetch(

return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
// 1. Get valid OAuth token (auto-refresh if needed)
const token = await ensureValidOAuthToken(tokenFilePath, provider);
const token = await ensureValidOAuthToken(
tokenFilePath,
provider,
init?.signal instanceof AbortSignal ? init.signal : undefined,
);
if (!token) {
throw new Error('OAuth: No valid token available. Please re-authenticate.');
}
Expand Down
Loading
Loading