Skip to content
Draft
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
2 changes: 1 addition & 1 deletion extensions/telegram-manager/src/agents/BaseAgent.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import EventEmitter from "events";
// plugins/telegram/src/agents/BaseAgent.ts
import fs from "fs";
import path from "path";
import EventEmitter from "events";
import { TelegramStorage } from "../storage/TelegramStorage";
import {
AgentRecord,
Expand Down
2 changes: 2 additions & 0 deletions src/auto-reply/reply/commands-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { handleApproveCommand } from "./commands-approve.js";
import { handleBashCommand } from "./commands-bash.js";
import { handleCompactCommand } from "./commands-compact.js";
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
import { handleDialogueCommand } from "./commands-dialogue.js";
import {
handleCommandsListCommand,
handleContextCommand,
Expand Down Expand Up @@ -56,6 +57,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
handleStatusCommand,
handleAllowlistCommand,
handleApproveCommand,
handleDialogueCommand,
handleContextCommand,
handleExportSessionCommand,
handleWhoamiCommand,
Expand Down
150 changes: 150 additions & 0 deletions src/auto-reply/reply/commands-dialogue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { updateSessionStoreEntry } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import type { CommandHandler } from "./commands-types.js";

const CONFIRM_COMMANDS = new Set(["/confirm", "/agree", "/yes"]);
const REJECT_COMMANDS = new Set(["/reject", "/cancel", "/no"]);
const DIALOGUE_COMMAND_PREFIX = "/dialogue";

/**
* Handles explicit /confirm (/agree, /yes) and /reject (/cancel, /no) commands as
* well as /dialogue management commands.
*
* /confirm – clears pendingConfirmation with a "confirmed" note.
* /reject – clears pendingConfirmation with a "rejected" note.
* /dialogue status – reports the current dialogue state.
* /dialogue confirm-required on|off – toggle confirmationRequired mode.
*/
export const handleDialogueCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const body = params.command.commandBodyNormalized.trim().toLowerCase();
const firstWord = body.split(/\s+/)[0];

// ── /confirm | /agree | /yes ──────────────────────────────────────────────
if (CONFIRM_COMMANDS.has(firstWord)) {
if (!params.command.isAuthorizedSender) {
logVerbose(`Ignoring ${firstWord} from unauthorized sender`);
return { shouldContinue: false };
}
const entry = params.sessionEntry;
const pending = entry?.pendingConfirmation;
if (!pending) {
return {
shouldContinue: false,
reply: { text: "ℹ️ There is nothing awaiting confirmation right now." },
};
}
if (params.storePath && params.sessionKey) {
await updateSessionStoreEntry({
storePath: params.storePath,
sessionKey: params.sessionKey,
update: async () => ({ pendingConfirmation: undefined }),
});
}
return {
shouldContinue: false,
reply: {
text:
`✅ Confirmed: "${pending.task}"\n\n` +
`Send your next message and the agent will proceed with the confirmed action.`,
},
};
}

// ── /reject | /cancel | /no ───────────────────────────────────────────────
if (REJECT_COMMANDS.has(firstWord)) {
if (!params.command.isAuthorizedSender) {
logVerbose(`Ignoring ${firstWord} from unauthorized sender`);
return { shouldContinue: false };
}
const entry = params.sessionEntry;
const pending = entry?.pendingConfirmation;
if (!pending) {
return {
shouldContinue: false,
reply: { text: "ℹ️ There is nothing awaiting confirmation right now." },
};
}
if (params.storePath && params.sessionKey) {
await updateSessionStoreEntry({
storePath: params.storePath,
sessionKey: params.sessionKey,
update: async () => ({ pendingConfirmation: undefined }),
});
}
return {
shouldContinue: false,
reply: {
text:
`❌ Cancelled: "${pending.task}"\n\n` +
`Send your next message to tell the agent what you'd like to do instead.`,
},
};
}

// ── /dialogue ─────────────────────────────────────────────────────────────
if (!body.startsWith(DIALOGUE_COMMAND_PREFIX)) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose("Ignoring /dialogue from unauthorized sender");
return { shouldContinue: false };
}

const rest = body.slice(DIALOGUE_COMMAND_PREFIX.length).trim();

// /dialogue status
if (!rest || rest === "status") {
const entry = params.sessionEntry;
const pending = entry?.pendingConfirmation;
const confirmRequired = entry?.confirmationRequired ?? false;
const lines: string[] = ["📋 **Dialogue state**"];
lines.push(confirmRequired ? "• Mode: confirmation-required ✅" : "• Mode: normal");
if (pending) {
const age = Math.round((Date.now() - pending.sentAt) / 1000);
lines.push(`• Pending confirmation (${age}s ago): "${pending.task}"`);
lines.push('• Use /confirm or /reject to respond, or just type "yes"/"no".');
} else {
lines.push("• No pending confirmation");
}
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}

// /dialogue confirm-required on|off
const crMatch = rest.match(/^confirm-required\s+(on|off|true|false|enable|disable)$/i);
if (crMatch) {
const value = /^(on|true|enable)$/i.test(crMatch[1]);
if (params.storePath && params.sessionKey) {
await updateSessionStoreEntry({
storePath: params.storePath,
sessionKey: params.sessionKey,
update: async () => ({ confirmationRequired: value }),
});
}
const label = value ? "enabled ✅" : "disabled";
return {
shouldContinue: false,
reply: {
text:
`✅ Confirmation-required mode ${label}. The agent will now ` +
(value
? "always ask for your agreement before taking significant actions."
: "proceed without requesting explicit confirmation."),
},
};
}

return {
shouldContinue: false,
reply: {
text:
"Usage:\n" +
"• `/dialogue status` – show current dialogue state\n" +
"• `/dialogue confirm-required on|off` – toggle confirmation-required mode\n" +
"• `/confirm` (or /agree, /yes) – confirm a pending action\n" +
"• `/reject` (or /cancel, /no) – reject/cancel a pending action",
},
};
};
122 changes: 122 additions & 0 deletions src/auto-reply/reply/dialogue-confirmation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import {
agentResponseRequestsConfirmation,
buildConfirmationRequiredSystemNote,
buildPendingConfirmationNote,
isConfirmationResponse,
isRejectionResponse,
} from "./dialogue-confirmation.js";

describe("isConfirmationResponse", () => {
it("returns true for 'yes'", () => {
expect(isConfirmationResponse("yes")).toBe(true);
});

it("returns true for 'ok'", () => {
expect(isConfirmationResponse("ok")).toBe(true);
});

it("returns true for Russian 'да'", () => {
expect(isConfirmationResponse("да")).toBe(true);
});

it("returns true for Russian 'ок'", () => {
expect(isConfirmationResponse("ок")).toBe(true);
});

it("returns true for 'confirm'", () => {
expect(isConfirmationResponse("confirm")).toBe(true);
});

it("returns true for 'yes!'", () => {
expect(isConfirmationResponse("yes!")).toBe(true);
});

it("returns true for 'согласен'", () => {
expect(isConfirmationResponse("согласен")).toBe(true);
});

it("returns false for empty string", () => {
expect(isConfirmationResponse("")).toBe(false);
});

it("returns false for unrelated text", () => {
expect(isConfirmationResponse("What time is it?")).toBe(false);
});
});

describe("isRejectionResponse", () => {
it("returns true for 'no'", () => {
expect(isRejectionResponse("no")).toBe(true);
});

it("returns true for 'cancel'", () => {
expect(isRejectionResponse("cancel")).toBe(true);
});

it("returns true for Russian 'нет'", () => {
expect(isRejectionResponse("нет")).toBe(true);
});

it("returns true for 'stop'", () => {
expect(isRejectionResponse("stop")).toBe(true);
});

it("returns true for 'отмена'", () => {
expect(isRejectionResponse("отмена")).toBe(true);
});

it("returns false for empty string", () => {
expect(isRejectionResponse("")).toBe(false);
});

it("returns false for unrelated text", () => {
expect(isRejectionResponse("What time is it?")).toBe(false);
});
});

describe("agentResponseRequestsConfirmation", () => {
it("returns true for 'Please confirm this action'", () => {
expect(agentResponseRequestsConfirmation("Please confirm this action.")).toBe(true);
});

it("returns true for 'Do you confirm?'", () => {
expect(agentResponseRequestsConfirmation("Do you confirm this?")).toBe(true);
});

it("returns true for 'Would you like me to proceed?'", () => {
expect(agentResponseRequestsConfirmation("Would you like me to proceed?")).toBe(true);
});

it("returns true for 'Shall I proceed?'", () => {
expect(agentResponseRequestsConfirmation("Shall I proceed?")).toBe(true);
});

it("returns true for Russian 'Подтвердите'", () => {
expect(agentResponseRequestsConfirmation("Подтвердите ваше решение.")).toBe(true);
});

it("returns false for regular text", () => {
expect(agentResponseRequestsConfirmation("I have completed the task.")).toBe(false);
});

it("returns false for empty string", () => {
expect(agentResponseRequestsConfirmation("")).toBe(false);
});
});

describe("buildPendingConfirmationNote", () => {
it("includes the task in the note", () => {
const note = buildPendingConfirmationNote("delete all files");
expect(note).toContain("delete all files");
expect(note).toContain("awaiting user confirmation");
});
});

describe("buildConfirmationRequiredSystemNote", () => {
it("contains the confirmation-required instructions", () => {
const note = buildConfirmationRequiredSystemNote();
expect(note).toContain("confirmation required");
expect(note.toLowerCase()).toContain("explicitly ask the user to confirm");
});
});
Loading