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
51 changes: 50 additions & 1 deletion src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import {
getDestructiveExecutables,
extractFilesToDelete,
} from './utils/extractExecutables';
import { createPermissionRequestId } from './utils/permissionRequest';
import {
validateCopilotCreateSessionArgs,
validateCopilotResumePreviousSessionArgs,
Expand Down Expand Up @@ -845,6 +846,7 @@ function registerSessionEventForwarding(sessionId: string, session: CopilotSessi
if (throttler) {
throttler.flush();
}
clearToolStallTimer(sessionId);
sessionSawDelta.set(sessionId, false);
const currentSessionState = sessions.get(sessionId);
if (currentSessionState) {
Expand Down Expand Up @@ -875,6 +877,7 @@ function registerSessionEventForwarding(sessionId: string, session: CopilotSessi
});
} else if (event.type === 'session.error') {
console.log(`[${sessionId}] Session error:`, event.data);
clearToolStallTimer(sessionId);
const errorMessage = event.data?.message || JSON.stringify(event.data);

// Auto-repair tool_result errors (duplicate or orphaned after compaction)
Expand Down Expand Up @@ -1021,6 +1024,14 @@ function startToolStallTimer(sessionId: string, toolName: string): void {
} catch (err) {
log.error(`[${sessionId}] Failed to abort stalled session:`, err);
}
const flushedPermissions = resolvePendingPermissionsForSession(sessionId, {
kind: 'denied-no-approval-rule-and-could-not-request-from-user',
});
if (flushedPermissions > 0) {
log.warn(
`[${sessionId}] Flushed ${flushedPermissions} pending permissions after stall abort`
);
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('copilot:error', {
sessionId,
Expand Down Expand Up @@ -1095,6 +1106,20 @@ const pendingPermissions = new Map<
// Track in-flight permission requests by session+executable to deduplicate parallel requests
const inFlightPermissions = new Map<string, Promise<PermissionRequestResult>>();

function resolvePendingPermissionsForSession(
sessionId: string,
result: PermissionRequestResult
): number {
let resolvedCount = 0;
for (const [requestId, pending] of pendingPermissions.entries()) {
if (pending.sessionId !== sessionId) continue;
pendingPermissions.delete(requestId);
pending.resolve(result);
resolvedCount++;
}
return resolvedCount;
}

let defaultClient: CopilotClient | null = null;

// Helper to get the default client
Expand Down Expand Up @@ -1216,6 +1241,7 @@ async function startEarlySessionResumption(): Promise<void> {
if (throttler) {
throttler.flush();
}
clearToolStallTimer(sessionId);
sessionSawDelta.set(sessionId, false);
const currentSessionState = sessions.get(sessionId);
if (currentSessionState) currentSessionState.isProcessing = false;
Expand Down Expand Up @@ -1649,7 +1675,7 @@ async function handlePermissionRequest(
_invocation: { sessionId: string },
ourSessionId: string
): Promise<PermissionRequestResult> {
const requestId = request.toolCallId || `perm-${Date.now()}`;
const requestId = createPermissionRequestId(request, ourSessionId);
const req = request as Record<string, unknown>;
const sessionState = sessions.get(ourSessionId);
const globalSafeCommands = new Set((store.get('globalSafeCommands') as string[]) || []);
Expand Down Expand Up @@ -2392,6 +2418,7 @@ async function initCopilot(): Promise<void> {
if (throttler) {
throttler.flush();
}
clearToolStallTimer(sessionId);
sessionSawDelta.set(sessionId, false);
const currentSessionState = sessions.get(sessionId);
if (currentSessionState) currentSessionState.isProcessing = false;
Expand Down Expand Up @@ -3191,6 +3218,10 @@ ipcMain.handle(
const preserveActiveAgentName = sessionState.activeAgentName;

// Destroy the old session
clearToolStallTimer(data.sessionId);
resolvePendingPermissionsForSession(data.sessionId, {
kind: 'denied-no-approval-rule-and-could-not-request-from-user',
});
await sessionState.session.destroy();
sessions.delete(data.sessionId);

Expand Down Expand Up @@ -3221,6 +3252,10 @@ ipcMain.handle(
log.info(
`[${data.sessionId}] Resuming session for model switch: ${previousModel} → ${data.model}`
);
clearToolStallTimer(data.sessionId);
resolvePendingPermissionsForSession(data.sessionId, {
kind: 'denied-no-approval-rule-and-could-not-request-from-user',
});
await sessionState.session.destroy();
sessions.delete(data.sessionId);

Expand Down Expand Up @@ -3641,6 +3676,10 @@ ipcMain.handle(
throttler.flush();
sessionDeltaThrottlers.delete(sessionId);
}
clearToolStallTimer(sessionId);
resolvePendingPermissionsForSession(sessionId, {
kind: 'denied-no-approval-rule-and-could-not-request-from-user',
});
await sessionState.session.destroy();
sessions.delete(sessionId);
sessionSawDelta.delete(sessionId);
Expand Down Expand Up @@ -5000,6 +5039,7 @@ ipcMain.handle(
if (throttler) {
throttler.flush();
}
clearToolStallTimer(sessionId);
sessionSawDelta.set(sessionId, false);
const currentSessionState = sessions.get(sessionId);
if (currentSessionState) currentSessionState.isProcessing = false;
Expand Down Expand Up @@ -5027,6 +5067,7 @@ ipcMain.handle(
});
} else if (event.type === 'session.error') {
console.log(`[${sessionId}] Session error:`, event.data);
clearToolStallTimer(sessionId);
const errorMessage = event.data?.message || JSON.stringify(event.data);

// Auto-repair tool_result errors (duplicate or orphaned after compaction)
Expand Down Expand Up @@ -5566,6 +5607,10 @@ app.on('window-all-closed', async () => {

// Destroy all sessions
for (const [id, state] of sessions) {
clearToolStallTimer(id);
resolvePendingPermissionsForSession(id, {
kind: 'denied-no-approval-rule-and-could-not-request-from-user',
});
await state.session.destroy();
console.log(`Destroyed session ${id}`);
}
Expand All @@ -5590,6 +5635,10 @@ app.on('before-quit', async () => {

// Destroy all sessions
for (const [id, state] of sessions) {
clearToolStallTimer(id);
resolvePendingPermissionsForSession(id, {
kind: 'denied-no-approval-rule-and-could-not-request-from-user',
});
await state.session.destroy();
}
sessions.clear();
Expand Down
40 changes: 40 additions & 0 deletions src/main/utils/permissionRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import {
createPermissionRequestId,
resetPermissionRequestIdCounterForTests,
} from './permissionRequest';

describe('createPermissionRequestId', () => {
beforeEach(() => {
resetPermissionRequestIdCounterForTests();
vi.spyOn(Date, 'now').mockReturnValue(1700000000000);
});

afterEach(() => {
vi.restoreAllMocks();
});

it('includes toolCallId when available and stays unique across requests', () => {
const request = {
kind: 'shell',
fullCommandText: 'echo hi',
intention: 'Echo',
toolCallId: 'tool-1',
};

const first = createPermissionRequestId(request as any, 'session-1');
const second = createPermissionRequestId(request as any, 'session-1');

expect(first).toMatch(/^tool-1:1700000000000:\d+$/);
expect(second).toMatch(/^tool-1:1700000000000:\d+$/);
expect(first).not.toBe(second);
});

it('falls back to session-based prefix when toolCallId is missing', () => {
const request = { kind: 'read', intention: 'Read file', path: '/tmp/a.txt' };

const requestId = createPermissionRequestId(request as any, 'session-abc');

expect(requestId).toMatch(/^perm-session-abc:1700000000000:\d+$/);
});
});
13 changes: 13 additions & 0 deletions src/main/utils/permissionRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { PermissionRequest } from '@github/copilot-sdk';

let permissionRequestCounter = 0;

export function createPermissionRequestId(request: PermissionRequest, sessionId: string): string {
const base = request.toolCallId || `perm-${sessionId}`;
permissionRequestCounter = (permissionRequestCounter + 1) % Number.MAX_SAFE_INTEGER;
return `${base}:${Date.now()}:${permissionRequestCounter}`;
}

export function resetPermissionRequestIdCounterForTests(): void {
permissionRequestCounter = 0;
}