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
125 changes: 125 additions & 0 deletions src/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
77 changes: 66 additions & 11 deletions src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2449,6 +2449,17 @@ export class CodexPluginController {
conversation: ConversationTarget,
message: InteractiveMessageRef,
statusCard: StatusCardRender,
): Promise<boolean> {
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<boolean> {
try {
if (message.provider === "telegram") {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand All @@ -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(
Expand 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 {
Expand All @@ -3991,18 +4027,35 @@ export class CodexPluginController {
opts?: {
editMessage?: (text: string, buttons: PluginInteractiveButtons) => Promise<void>;
},
): Promise<void> {
): Promise<DeliveredMessageRef | null> {
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<void> {
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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.");
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ export type StoredPendingRequest = {
threadId: string;
workspaceDir: string;
state: PendingInputState;
pendingMessage?: InteractiveMessageRef;
createdAt?: number;
updatedAt: number;
};
Expand Down