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
50 changes: 49 additions & 1 deletion src/app/managers/permission-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,26 @@ class PermissionManager {
private state: PermissionState = {
requestsByMessageId: new Map(),
};
private resolvedRequestIDs = new Set<string>();
private generation = 0;

/**
* Register a new permission request message
*/
startPermission(request: PermissionRequest, messageId: number): void {
startPermission(
request: PermissionRequest,
messageId: number,
generation: number = this.generation,
): boolean {
logger.debug(
`[PermissionManager] startPermission: id=${request.id}, permission=${request.permission}, messageId=${messageId}`,
);

if (generation !== this.generation || this.resolvedRequestIDs.has(request.id)) {
logger.debug(`[PermissionManager] Ignoring stale or already resolved request: id=${request.id}`);
return false;
}

if (this.state.requestsByMessageId.has(messageId)) {
logger.warn(`[PermissionManager] Message ID already tracked, replacing: ${messageId}`);
}
Expand All @@ -23,6 +34,8 @@ class PermissionManager {
logger.info(
`[PermissionManager] New permission request: type=${request.permission}, patterns=${request.patterns.join(", ")}, pending=${this.state.requestsByMessageId.size}`,
);

return true;
}

/**
Expand Down Expand Up @@ -101,6 +114,39 @@ class PermissionManager {
return request;
}

/**
* Remove all Telegram messages tracking an OpenCode permission request ID
*/
resolveRequest(requestID: string): number[] {
this.resolvedRequestIDs.add(requestID);
const removedMessageIds: number[] = [];

for (const [messageId, request] of this.state.requestsByMessageId) {
if (request.id !== requestID) {
continue;
}

this.state.requestsByMessageId.delete(messageId);
removedMessageIds.push(messageId);
}

if (removedMessageIds.length > 0) {
logger.debug(
`[PermissionManager] Removed resolved permission request: id=${requestID}, messages=${removedMessageIds.length}, pending=${this.state.requestsByMessageId.size}`,
);
}

return removedMessageIds;
}

isResolved(requestID: string): boolean {
return this.resolvedRequestIDs.has(requestID);
}

getGeneration(): number {
return this.generation;
}

/**
* Get number of active permission requests
*/
Expand All @@ -126,6 +172,8 @@ class PermissionManager {
this.state = {
requestsByMessageId: new Map(),
};
this.resolvedRequestIDs.clear();
this.generation++;
}
}

Expand Down
45 changes: 41 additions & 4 deletions src/app/managers/summary-aggregation-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ type SessionRetryCallback = (retryInfo: SessionRetryInfo) => void;

type SessionIdleCallback = (sessionId: string) => void;

type PermissionCallback = (request: PermissionRequest) => void;
type PermissionCallback = (request: PermissionRequest) => void | Promise<void>;

type PermissionRepliedCallback = (sessionId: string, requestID: string) => void | Promise<void>;

type SessionDiffCallback = (sessionId: string, diffs: FileChange[]) => void;

Expand Down Expand Up @@ -251,6 +253,7 @@ class SummaryAggregator {
private onSessionRetryCallback: SessionRetryCallback | null = null;
private onSessionIdleCallback: SessionIdleCallback | null = null;
private onPermissionCallback: PermissionCallback | null = null;
private onPermissionRepliedCallback: PermissionRepliedCallback | null = null;
private onSessionDiffCallback: SessionDiffCallback | null = null;
private onFileChangeCallback: FileChangeCallback | null = null;
private onClearedCallback: ClearedCallback | null = null;
Expand Down Expand Up @@ -350,6 +353,10 @@ class SummaryAggregator {
this.onPermissionCallback = callback;
}

setOnPermissionReplied(callback: PermissionRepliedCallback): void {
this.onPermissionRepliedCallback = callback;
}

setOnSessionDiff(callback: SessionDiffCallback): void {
this.onSessionDiffCallback = callback;
}
Expand Down Expand Up @@ -466,7 +473,7 @@ class SummaryAggregator {
this.handlePermissionAsked(event);
break;
case "permission.replied":
logger.info(`[Aggregator] Permission replied: requestID=${event.properties.requestID}`);
this.handlePermissionReplied(event);
break;
default:
logger.debug(`[Aggregator] Unhandled event type: ${event.type}`);
Expand Down Expand Up @@ -2110,11 +2117,41 @@ class SummaryAggregator {

if (this.onPermissionCallback) {
const callback = this.onPermissionCallback;
try {
void Promise.resolve(callback(request as PermissionRequest)).catch((err) => {
logger.error("[Aggregator] Error in permission callback:", err);
});
} catch (err) {
logger.error("[Aggregator] Error in permission callback:", err);
}
}
}

private handlePermissionReplied(
event: Event & {
type: "permission.replied";
},
): void {
const { sessionID, requestID } = event.properties;
const isCurrent = sessionID === this.currentSessionId;
const isTrackedChild = this.isTrackedChildSession(sessionID);

if (!isCurrent && !isTrackedChild) {
logger.debug(
`[Aggregator] Ignoring permission.replied for different session: ${sessionID} (current: ${this.currentSessionId})`,
);
return;
}

logger.info(`[Aggregator] Permission replied: requestID=${requestID}`);

if (this.onPermissionRepliedCallback) {
const callback = this.onPermissionRepliedCallback;
setImmediate(async () => {
try {
await callback(request as PermissionRequest);
await callback(sessionID, requestID);
} catch (err) {
logger.error("[Aggregator] Error in permission callback:", err);
logger.error("[Aggregator] Error in permission replied callback:", err);
}
});
}
Expand Down
26 changes: 26 additions & 0 deletions src/bot/callbacks/permission-callback-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ function isPermissionReply(value: string): value is PermissionReply {
return value === "once" || value === "always" || value === "reject";
}

function isPermissionRequestNotFound(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}

const candidate = error as {
name?: unknown;
message?: unknown;
data?: { message?: unknown };
};

if (candidate.name === "NotFoundError") {
return true;
}

return [candidate.message, candidate.data?.message].some(
(message) =>
typeof message === "string" && message.toLowerCase().includes("permission request not found"),
);
}

export async function handlePermissionCallback(ctx: Context): Promise<boolean> {
const data = ctx.callbackQuery?.data;
if (!data) return false;
Expand Down Expand Up @@ -121,6 +142,11 @@ async function handlePermissionReply(
}),
onSuccess: ({ error }) => {
if (error) {
if (isPermissionRequestNotFound(error)) {
logger.debug(`[PermissionHandler] Permission request already resolved: ${requestID}`);
return;
}

logger.error("[PermissionHandler] Failed to send permission reply:", error);
if (ctx.api && chatId) {
void ctx.api.sendMessage(chatId, t("permission.send_reply_error")).catch(() => {});
Expand Down
16 changes: 15 additions & 1 deletion src/bot/menus/permission-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,18 @@ export async function showPermissionRequest(
bot: Context["api"],
chatId: number,
request: PermissionRequest,
generation: number = permissionManager.getGeneration(),
): Promise<void> {
logger.debug(`[PermissionHandler] Showing permission request: ${request.permission}`);

if (
generation !== permissionManager.getGeneration() ||
permissionManager.isResolved(request.id)
) {
logger.debug(`[PermissionHandler] Skipping stale or already resolved request: ${request.id}`);
return;
}

const text = formatPermissionText(request);
const keyboard = buildPermissionKeyboard();

Expand All @@ -94,7 +103,12 @@ export async function showPermissionRequest(
});

logger.debug(`[PermissionHandler] Message sent, messageId=${message.message_id}`);
permissionManager.startPermission(request, message.message_id);
if (!permissionManager.startPermission(request, message.message_id, generation)) {
await bot.deleteMessage(chatId, message.message_id).catch((err) => {
logger.warn(`[PermissionHandler] Failed to delete stale permission message:`, err);
});
return;
}

syncPermissionInteractionState({
requestID: request.id,
Expand Down
38 changes: 35 additions & 3 deletions src/bot/services/event-subscription-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,13 @@ import {
} from "../../app/managers/background-session-manager.js";
import { buildBackgroundSessionOpenKeyboard } from "../menus/session-selection-menu.js";
import { questionManager } from "../../app/managers/question-manager.js";
import { permissionManager } from "../../app/managers/permission-manager.js";
import { showCurrentQuestion } from "../menus/question-menu.js";
import { showPermissionRequest } from "../menus/permission-menu.js";
import { clearAllInteractionState } from "../../app/managers/interaction-manager.js";
import { showPermissionRequest, syncPermissionInteractionState } from "../menus/permission-menu.js";
import {
clearAllInteractionState,
interactionManager,
} from "../../app/managers/interaction-manager.js";
import { stopEventListening, subscribeToEvents } from "../../opencode/events.js";

const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
Expand Down Expand Up @@ -679,6 +683,8 @@ class EventSubscriptionService implements BotEventSubscriptionService {
});

summaryAggregator.setOnPermission(async (request) => {
const generation = permissionManager.getGeneration();

if (!this.botInstance || !this.chatIdInstance) {
logger.error("Bot or chat ID not available for showing permission request");
return;
Expand All @@ -703,7 +709,33 @@ class EventSubscriptionService implements BotEventSubscriptionService {
logger.info(
`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}, subagent=${isSubagent}`,
);
await showPermissionRequest(this.botInstance.api, this.chatIdInstance, request);
await showPermissionRequest(this.botInstance.api, this.chatIdInstance, request, generation);
});

summaryAggregator.setOnPermissionReplied(async (_sessionId, requestID) => {
const messageIds = permissionManager.resolveRequest(requestID);
const interaction = interactionManager.getSnapshot();
if (!permissionManager.isActive() || !interaction || interaction.kind === "permission") {
syncPermissionInteractionState({ resolvedRequestID: requestID });
}

if (this.botInstance && this.chatIdInstance) {
const api = this.botInstance.api;
const chatId = this.chatIdInstance;
await Promise.all(
messageIds.map((messageId) =>
api.deleteMessage(chatId, messageId).catch((err) => {
logger.warn(`[Bot] Failed to delete resolved permission message ${messageId}:`, err);
}),
),
);
}

if (messageIds.length > 0) {
logger.info(
`[Bot] Cleared resolved permission prompt: requestID=${requestID}, messages=${messageIds.length}`,
);
}
});

summaryAggregator.setOnThinking(async (update) => {
Expand Down
4 changes: 1 addition & 3 deletions tests/app/managers/summary-aggregation-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1826,7 +1826,7 @@ describe("summary/aggregator", () => {
expect(onTokens).not.toHaveBeenCalled();
});

it("forwards permission.asked events from tracked subagent (child) sessions", async () => {
it("forwards permission.asked events from tracked subagent (child) sessions synchronously", () => {
const onPermission = vi.fn();
summaryAggregator.setOnPermission(onPermission);
summaryAggregator.setSession("root-session");
Expand Down Expand Up @@ -1876,8 +1876,6 @@ describe("summary/aggregator", () => {
},
} as unknown as Event);

await new Promise((resolve) => setImmediate(resolve));

expect(onPermission).toHaveBeenCalledTimes(1);
expect(onPermission.mock.calls[0][0]).toEqual(
expect.objectContaining({
Expand Down
Loading