diff --git a/src/controller.test.ts b/src/controller.test.ts index 54378f9..6dbd12b 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -1335,6 +1335,131 @@ describe("Discord controller flows", () => { expect(followUp).not.toHaveBeenCalled(); }); + it("clears superseded Telegram pending-input buttons before sending the next prompt", async () => { + const { controller, sendMessageTelegram } = await createControllerHarness(); + const conversation = { + channel: "telegram", + accountId: "default", + conversationId: "123", + }; + const run = { + getThreadId: vi.fn(() => "thread-1"), + }; + + await (controller as any).handlePendingInputState( + conversation, + "/repo/openclaw", + { + requestId: "pending-1", + options: ["Approve Once"], + actions: [ + { + kind: "approval", + label: "Approve Once", + decision: "accept", + responseDecision: "accept", + }, + ], + expiresAt: Date.now() + 60_000, + promptText: "Codex command approval requested (pending-1)", + }, + run, + ); + + await (controller as any).handlePendingInputState( + conversation, + "/repo/openclaw", + { + requestId: "pending-2", + options: ["Approve Once"], + actions: [ + { + kind: "approval", + label: "Approve Once", + decision: "accept", + responseDecision: "accept", + }, + ], + expiresAt: Date.now() + 60_000, + promptText: "Codex command approval requested (pending-2)", + }, + run, + ); + + expect(sendMessageTelegram).toHaveBeenCalledTimes(2); + expect((controller as any).store.getPendingRequestById("pending-1")).toBeNull(); + expect((controller as any).store.getPendingRequestById("pending-2")).toEqual( + expect.objectContaining({ + requestId: "pending-2", + }), + ); + + const fetchMock = vi.mocked(fetch); + const editCall = fetchMock.mock.calls.find((call) => + String(call[0]).includes("/editMessageText"), + ); + expect(editCall).toBeDefined(); + const body = JSON.parse(String((editCall?.[1] as RequestInit | undefined)?.body ?? "{}")); + expect(body).toEqual( + expect.objectContaining({ + chat_id: "123", + message_id: 1, + text: "Codex command approval requested (pending-1)", + reply_markup: { + inline_keyboard: [], + }, + }), + ); + }); + + it("clears Telegram pending-input buttons when the request is removed", async () => { + const { controller } = await createControllerHarness(); + const conversation = { + channel: "telegram", + accountId: "default", + conversationId: "123", + }; + const run = { + getThreadId: vi.fn(() => "thread-1"), + }; + + await (controller as any).handlePendingInputState( + conversation, + "/repo/openclaw", + { + requestId: "pending-1", + options: ["Approve Once"], + actions: [ + { + kind: "approval", + label: "Approve Once", + decision: "accept", + responseDecision: "accept", + }, + ], + expiresAt: Date.now() + 60_000, + promptText: "Codex command approval requested (pending-1)", + }, + run, + ); + + await (controller as any).handlePendingInputState( + conversation, + "/repo/openclaw", + null, + run, + ); + + expect((controller as any).store.getPendingRequestById("pending-1")).toBeNull(); + const fetchMock = vi.mocked(fetch); + const editCall = fetchMock.mock.calls.find((call) => + String(call[0]).includes("/editMessageText"), + ); + expect(editCall).toBeDefined(); + const body = JSON.parse(String((editCall?.[1] as RequestInit | undefined)?.body ?? "{}")); + expect(body.reply_markup).toEqual({ inline_keyboard: [] }); + }); + it("does not send a second Discord response after completing a questionnaire", async () => { const { controller } = await createControllerHarness(); await (controller as any).store.upsertPendingRequest({ diff --git a/src/controller.ts b/src/controller.ts index 72ec97b..5faec9e 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -2449,6 +2449,17 @@ export class CodexPluginController { conversation: ConversationTarget, message: InteractiveMessageRef, statusCard: StatusCardRender, + ): Promise { + return await this.updateInteractiveMessage(conversation, message, { + text: statusCard.text, + buttons: statusCard.buttons, + }); + } + + private async updateInteractiveMessage( + conversation: ConversationTarget, + message: InteractiveMessageRef, + content: { text: string; buttons?: PluginInteractiveButtons }, ): Promise { try { if (message.provider === "telegram") { @@ -2459,21 +2470,21 @@ export class CodexPluginController { await this.callTelegramEditMessageApi(token, { chat_id: message.chatId, message_id: Number(message.messageId), - text: statusCard.text, - reply_markup: buildTelegramReplyMarkup(statusCard.buttons) ?? { inline_keyboard: [] }, + text: content.text, + reply_markup: buildTelegramReplyMarkup(content.buttons) ?? { inline_keyboard: [] }, }); return true; } const builtPicker = this.buildDiscordPickerMessage({ - text: statusCard.text, - buttons: statusCard.buttons, + text: content.text, + buttons: content.buttons, }); await editDiscordComponentMessage( message.channelId, message.messageId, this.buildDiscordPickerSpec({ - text: statusCard.text, - buttons: statusCard.buttons, + text: content.text, + buttons: content.buttons, }), { accountId: conversation.accountId, @@ -3474,6 +3485,7 @@ export class CodexPluginController { this.activeRuns.delete(key); const pending = this.store.getPendingRequestByConversation(params.conversation); if (pending) { + await this.clearPendingRequestMessage(params.conversation, pending); await this.store.removePendingRequest(pending.requestId); } await this.applyPendingBindingPermissionsModeMigration(params.conversation); @@ -3747,6 +3759,7 @@ export class CodexPluginController { this.activeRuns.delete(key); const pending = this.store.getPendingRequestByConversation(params.conversation); if (pending) { + await this.clearPendingRequestMessage(params.conversation, pending); await this.store.removePendingRequest(pending.requestId); } await this.applyPendingBindingPermissionsModeMigration(params.conversation); @@ -3925,10 +3938,16 @@ export class CodexPluginController { if (!state) { const existing = this.store.getPendingRequestByConversation(conversation); if (existing) { + await this.clearPendingRequestMessage(conversation, existing); await this.store.removePendingRequest(existing.requestId); } return; } + const superseded = this.store.getPendingRequestByConversation(conversation); + if (superseded && superseded.requestId !== state.requestId) { + await this.clearPendingRequestMessage(conversation, superseded); + await this.store.removePendingRequest(superseded.requestId); + } if (state.questionnaire) { const existing = this.store.getPendingRequestById(state.requestId); await this.store.upsertPendingRequest({ @@ -3937,10 +3956,23 @@ export class CodexPluginController { threadId: run.getThreadId() ?? this.store.getBinding(conversation)?.threadId ?? "", workspaceDir, state, + pendingMessage: existing?.pendingMessage, createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(), }); - await this.sendPendingQuestionnaire(conversation, state); + const delivered = await this.sendPendingQuestionnaire(conversation, state); + if (delivered) { + await this.store.upsertPendingRequest({ + requestId: state.requestId, + conversation, + threadId: run.getThreadId() ?? this.store.getBinding(conversation)?.threadId ?? "", + workspaceDir, + state, + pendingMessage: delivered, + createdAt: existing?.createdAt ?? Date.now(), + updatedAt: Date.now(), + }); + } return; } const callbacks = await Promise.all( @@ -3956,16 +3988,20 @@ export class CodexPluginController { ); const buttons = this.buildPendingButtons(state, callbacks); const existing = this.store.getPendingRequestById(state.requestId); + const delivered = await this.sendReplyWithDeliveryRef(conversation, { + text: state.promptText ?? "Codex needs input.", + buttons, + }); await this.store.upsertPendingRequest({ requestId: state.requestId, conversation, threadId: run.getThreadId() ?? this.store.getBinding(conversation)?.threadId ?? "", workspaceDir, state, + pendingMessage: delivered ?? existing?.pendingMessage, createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(), }); - await this.sendText(conversation, state.promptText ?? "Codex needs input.", { buttons }); } private buildQuestionnaireSubmissionPayload(pending: StoredPendingRequest): unknown { @@ -3991,18 +4027,35 @@ export class CodexPluginController { opts?: { editMessage?: (text: string, buttons: PluginInteractiveButtons) => Promise; }, - ): Promise { + ): Promise { const questionnaire = state.questionnaire; if (!questionnaire) { - return; + return null; } const buttons = await this.buildPendingQuestionnaireButtons(conversation, state); const text = formatPendingQuestionnairePrompt(questionnaire); if (opts?.editMessage) { await opts.editMessage(text, buttons); + return null; + } + return await this.sendReplyWithDeliveryRef(conversation, { text, buttons }); + } + + private async clearPendingRequestMessage( + conversation: ConversationTarget, + pending: StoredPendingRequest, + ): Promise { + if (!pending.pendingMessage) { return; } - await this.sendText(conversation, text, { buttons }); + const text = + pending.state.questionnaire + ? formatPendingQuestionnairePrompt(pending.state.questionnaire) + : pending.state.promptText?.trim() || "That Codex action is no longer active."; + await this.updateInteractiveMessage(conversation, pending.pendingMessage, { + text, + buttons: [], + }).catch(() => undefined); } private async buildPendingQuestionnaireButtons( @@ -4136,6 +4189,7 @@ export class CodexPluginController { if (!submitted) { return false; } + await this.clearPendingRequestMessage(conversation, pending); await this.store.removePendingRequest(pending.requestId); await this.sendText(conversation, "Recorded your answers and sent them to Codex."); return true; @@ -5054,6 +5108,7 @@ export class CodexPluginController { return; } await responders.clear().catch(() => undefined); + await this.clearPendingRequestMessage(callback.conversation, pending); await this.store.removePendingRequest(pending.requestId); if (callback.conversation.channel !== "discord") { await responders.reply("Recorded your answers and sent them to Codex."); diff --git a/src/types.ts b/src/types.ts index f6e161f..db64cc9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -314,6 +314,7 @@ export type StoredPendingRequest = { threadId: string; workspaceDir: string; state: PendingInputState; + pendingMessage?: InteractiveMessageRef; createdAt?: number; updatedAt: number; };