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 scripts/source-file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"src/websocket/listener/commands/channels.ts": 1315,
"src/websocket/listener/commands/memory.ts": 1114,
"src/websocket/listener/file-commands.ts": 1030,
"src/websocket/listener/lifecycle.ts": 1617,
"src/websocket/listener/lifecycle.ts": 1597,
"src/websocket/listener/protocol-inbound.ts": 2332,
"src/websocket/listener/protocol-outbound.ts": 1100
}
9 changes: 8 additions & 1 deletion src/channels/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,21 @@ Typed slash commands are handled before normal channel ingress so operational
commands do not get delivered to the agent as regular user messages. The shared
channel command set is:

- `/help` — show channel usage and available commands.
- `/status` — show account, listener, route, agent, and conversation state.
- `/whoami` — show the sender's access scope, tier, and runnable commands.
- `/pause` — disable agent replies for the current routed chat.
- `/resume` — re-enable agent replies for the current routed chat.
- `/cancel` — abort the in-progress agent turn for the current routed chat.
- `/compact [mode]` — summarize the current route's active conversation
history; accepts the same modes as the CLI command.
- `/chat` — show the Letta web chat link for the current route.
- `/whoami` — show the sender's access scope, tier, and runnable commands.
- `/feedback <message>` — send product feedback with route context.
- `/model [list|handle]` — show, list, or switch the routed conversation's
model.
- `/reflection` — start a memory reflection pass for the current route's agent
conversation when MemFS is enabled.
- `/reload` — reload settings, local mods, and agent secrets.

Slack-native slash command payloads currently exist only for `/cancel`; the rest
are expected to be sent as normal channel messages in the relevant chat/thread.
Expand Down
5 changes: 4 additions & 1 deletion src/channels/access-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,13 +228,14 @@ describe("channel command tiers", () => {
expect(canRunChannelCommand(userGate, "whoami")).toBe(true);
expect(canRunChannelCommand(userGate, "pause")).toBe(false);
expect(canRunChannelCommand(userGate, "model")).toBe(false);
expect(canRunChannelCommand(userGate, "compact")).toBe(false);
expect(canRunChannelCommand(userGate, "reload")).toBe(false);
});

test("userAllowedCommands extends the floor and normalizes names", () => {
const account = makeAccount({
adminUsers: ["admin-1"],
userAllowedCommands: ["/Model", "cancel", "reload"],
userAllowedCommands: ["/Model", "cancel", "compact", "reload"],
});
const gate = resolveChannelCommandGate({
account,
Expand All @@ -243,6 +244,7 @@ describe("channel command tiers", () => {
});
expect(canRunChannelCommand(gate, "model")).toBe(true);
expect(canRunChannelCommand(gate, "cancel")).toBe(true);
expect(canRunChannelCommand(gate, "compact")).toBe(true);
expect(canRunChannelCommand(gate, "reload")).toBe(true);
expect(canRunChannelCommand(gate, "pause")).toBe(false);
});
Expand Down Expand Up @@ -308,6 +310,7 @@ describe("channel command tiers", () => {
expect(canRunChannelCommand(gate, "whoami")).toBe(true);
expect(canRunChannelCommand(gate, "cancel")).toBe(false);
expect(canRunChannelCommand(gate, "model")).toBe(false);
expect(canRunChannelCommand(gate, "compact")).toBe(false);
expect(canRunChannelCommand(gate, "pause")).toBe(false);
expect(canRunChannelCommand(gate, "reload")).toBe(false);

Expand Down
18 changes: 11 additions & 7 deletions src/channels/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ describe("channel slash commands", () => {
"pause",
"resume",
"cancel",
"compact",
"chat",
"feedback",
"model",
Expand All @@ -265,7 +266,7 @@ describe("channel slash commands", () => {
expect(text).toContain("Telegram is connected to Letta Code.");
expect(text).not.toContain("MessageChannel");
expect(text).toContain(
"Supported slash commands here: /help, /status, /whoami, /pause, /resume, /cancel, /chat, /feedback, /model, /reflection, /reload.",
"Supported slash commands here: /help, /status, /whoami, /pause, /resume, /cancel, /compact, /chat, /feedback, /model, /reflection, /reload.",
);

const slackText = buildChannelHelpMessage("slack");
Expand All @@ -284,12 +285,15 @@ describe("channel slash commands", () => {
"@agent /model <handle-or-id> - switch this thread's model",
);
expect(slackText).toContain("@agent /feedback <message>");
expect(slackText).toContain(
"@agent /compact - summarize this thread's active conversation history",
);
expect(slackText).toContain("@agent /detach");
expect(slackText).toContain(
"@agent /reload - reload settings, local mods, and agent secrets",
);
expect(slackText).toContain(
"Legacy bang aliases still work after a mention: !help, !detach, !model, !new, !reload.",
"Legacy bang aliases still work after a mention: !help, !compact, !detach, !model, !new, !reload.",
);
});

Expand Down Expand Up @@ -834,17 +838,17 @@ describe("channel slash commands", () => {
});

test("builds a useful unsupported-command response", () => {
const command = parseChannelSlashCommand("/compact now");
const command = parseChannelSlashCommand("/doctor now");
expect(command).not.toBeNull();
if (!command) {
throw new Error("Expected /compact to parse as a channel slash command");
throw new Error("Expected /doctor to parse as a channel slash command");
}

const text = buildUnsupportedChannelCommandMessage("telegram", command);
expect(text).toContain("Telegram received /compact now");
expect(text).toContain("Telegram received /doctor now");
expect(text).toContain("not supported in channels yet");
expect(text).toContain(
"Supported slash commands: /help, /status, /whoami, /pause, /resume, /cancel, /chat, /feedback, /model, /reflection, /reload.",
"Supported slash commands: /help, /status, /whoami, /pause, /resume, /cancel, /compact, /chat, /feedback, /model, /reflection, /reload.",
);
expect(text).toContain("without a leading slash");

Expand All @@ -867,7 +871,7 @@ describe("channel slash commands", () => {
);
expect(bangText).toContain("Slack received !pause");
expect(bangText).toContain(
"Supported bang commands: !help, !detach, !model, !new, !reload.",
"Supported bang commands: !help, !compact, !detach, !model, !new, !reload.",
);
});
});
72 changes: 36 additions & 36 deletions src/channels/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,48 +37,27 @@ export type ChannelSlashCommandHandlerResult = {
modelPicker?: ChannelModelPickerData;
};

type ChannelSlashCommandHandler = (
command: ParsedChannelSlashCommand,
msg: InboundChannelMessage,
) => Promise<ChannelSlashCommandHandlerResult>;

type ChannelDirectReplyPayload = {
text: string;
modelPicker?: ChannelModelPickerData;
};

export type ChannelSlashCommandHandlers = {
cancel?: (
command: ParsedChannelSlashCommand,
msg: InboundChannelMessage,
) => Promise<ChannelSlashCommandHandlerResult>;
chat?: (
command: ParsedChannelSlashCommand,
msg: InboundChannelMessage,
) => Promise<ChannelSlashCommandHandlerResult>;
detach?: (
command: ParsedChannelSlashCommand,
msg: InboundChannelMessage,
) => Promise<ChannelSlashCommandHandlerResult>;
model?: (
command: ParsedChannelSlashCommand,
msg: InboundChannelMessage,
) => Promise<ChannelSlashCommandHandlerResult>;
newConversation?: (
command: ParsedChannelSlashCommand,
msg: InboundChannelMessage,
) => Promise<ChannelSlashCommandHandlerResult>;
pause?: (
command: ParsedChannelSlashCommand,
msg: InboundChannelMessage,
) => Promise<ChannelSlashCommandHandlerResult>;
reflection?: (
command: ParsedChannelSlashCommand,
msg: InboundChannelMessage,
) => Promise<ChannelSlashCommandHandlerResult>;
reload?: (
command: ParsedChannelSlashCommand,
msg: InboundChannelMessage,
) => Promise<ChannelSlashCommandHandlerResult>;
resume?: (
command: ParsedChannelSlashCommand,
msg: InboundChannelMessage,
) => Promise<ChannelSlashCommandHandlerResult>;
cancel?: ChannelSlashCommandHandler;
compact?: ChannelSlashCommandHandler;
chat?: ChannelSlashCommandHandler;
detach?: ChannelSlashCommandHandler;
model?: ChannelSlashCommandHandler;
newConversation?: ChannelSlashCommandHandler;
pause?: ChannelSlashCommandHandler;
reflection?: ChannelSlashCommandHandler;
reload?: ChannelSlashCommandHandler;
resume?: ChannelSlashCommandHandler;
};

export type ChannelStatusContext = {
Expand Down Expand Up @@ -127,6 +106,11 @@ const CHANNEL_SLASH_COMMANDS: ChannelSlashCommandDefinition[] = [
kind: "agent-scoped",
summary: "Cancel the in-progress agent turn for this chat.",
},
{
name: "compact",
kind: "agent-scoped",
summary: "Summarize this conversation's active history.",
},
{
name: "chat",
kind: "direct",
Expand Down Expand Up @@ -158,6 +142,7 @@ const CHANNEL_SLASH_COMMANDS: ChannelSlashCommandDefinition[] = [

const SLACK_MENTION_COMMAND_NAMES = [
"help",
"compact",
"detach",
"model",
"new",
Expand Down Expand Up @@ -282,6 +267,7 @@ const SLACK_MENTION_SLASH_COMMAND_EXAMPLES = [
"@agent /model list",
"@agent /model <handle-or-id>",
"@agent /cancel",
"@agent /compact",
"@agent /chat",
"@agent /feedback <message>",
"@agent /reflection",
Expand Down Expand Up @@ -337,6 +323,7 @@ export function buildChannelHelpMessage(channelId: string): string {
"@agent /model <handle-or-id> - switch this thread's model",
"@agent /status - show route and listener status",
"@agent /cancel - cancel the current turn",
"@agent /compact - summarize this thread's active conversation history",
"@agent /chat - show the web chat link",
"@agent /feedback <message> - send feedback to the Letta team from this routed thread",
"@agent /reflection - start a memory reflection pass",
Expand Down Expand Up @@ -801,6 +788,13 @@ export function buildChannelReflectionUnavailableMessage(
return `${displayName} cannot start reflection for this chat because the listener is not ready yet. Try again in a moment.`;
}

export function buildChannelCompactUnavailableMessage(
channelId: string,
): string {
const displayName = channelDisplayName(channelId);
return `${displayName} cannot compact this chat's routed conversation because the listener is not ready yet. Try again in a moment.`;
}

export function buildChannelReloadUnavailableMessage(
channelId: string,
): string {
Expand Down Expand Up @@ -920,6 +914,12 @@ export async function tryHandleChannelSlashCommand(
handler: options.handlers?.cancel,
defaultText: buildChannelCancelAcceptedMessage(msg.channel),
});
case "compact":
return handleScopedCommand({
msg,
command,
handler: options.handlers?.compact,
});
case "chat":
return handleScopedCommand({
msg,
Expand Down
4 changes: 2 additions & 2 deletions src/channels/registry-command-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ describe("ChannelRegistry command routing", () => {
chatId: "123",
senderId: "456",
senderName: "Alice",
text: "/compact now",
text: "/doctor now",
timestamp: Date.now(),
messageId: "77",
chatType: "direct",
Expand All @@ -229,7 +229,7 @@ describe("ChannelRegistry command routing", () => {
replyToMessageId: "77",
});
expect(replies[0]?.text).toContain(
"Telegram received /compact now, but that slash command is not supported in channels yet.",
"Telegram received /doctor now, but that slash command is not supported in channels yet.",
);
});

Expand Down
33 changes: 33 additions & 0 deletions src/channels/registry-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
buildChannelCancelUnavailableMessage,
buildChannelChatLinkMessage,
buildChannelChatUnavailableMessage,
buildChannelCompactUnavailableMessage,
buildChannelDetachedMessage,
buildChannelDetachUnsupportedMessage,
buildChannelModelUnavailableMessage,
Expand All @@ -22,6 +23,7 @@ import {
import type { ChannelRegistryEvent } from "./registry-events";
import type {
ChannelCancelHandler,
ChannelCompactHandler,
ChannelModelHandler,
ChannelReflectionHandler,
ChannelReloadHandler,
Expand Down Expand Up @@ -53,6 +55,7 @@ export function createChannelCommandRouter(deps: {
threadId?: string | null,
) => ChannelRoute | null;
getCancelHandler: () => ChannelCancelHandler | null;
getCompactHandler: () => ChannelCompactHandler | null;
getReflectionHandler: () => ChannelReflectionHandler | null;
getReloadHandler: () => ChannelReloadHandler | null;
getModelHandler: () => ChannelModelHandler | null;
Expand Down Expand Up @@ -143,6 +146,35 @@ export function createChannelCommandRouter(deps: {
return { handled: true };
}

async function handleCompactSlashCommand(
command: { args: string },
msg: InboundChannelMessage,
): Promise<{ handled: boolean; text?: string }> {
const route = loadAndFindRawRouteForMessage(msg);
if (!route?.enabled) {
return {
handled: true,
text: buildChannelNoRouteMessage(msg.channel),
};
}

const compactHandler = deps.getCompactHandler();
if (!compactHandler) {
return {
handled: true,
text: buildChannelCompactUnavailableMessage(msg.channel),
};
}

return compactHandler({
runtime: {
agent_id: route.agentId,
conversation_id: route.conversationId,
},
...(command.args ? { args: command.args } : {}),
});
}

async function handleChatSlashCommand(
msg: InboundChannelMessage,
): Promise<{ handled: boolean; text?: string }> {
Expand Down Expand Up @@ -448,6 +480,7 @@ export function createChannelCommandRouter(deps: {
return {
handleCancelSlashCommand,
handleChatSlashCommand,
handleCompactSlashCommand,
handleDetachSlashCommand,
handleModelSlashCommand,
handleNewConversationSlashCommand,
Expand Down
Loading