feat(queue): add queue modes and discord gating
This commit is contained in:
@@ -85,6 +85,26 @@ Group messages default to **require mention** (either metadata mention or regex
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `routing.queue`
|
||||||
|
|
||||||
|
Controls how inbound messages behave when an agent run is already active.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
routing: {
|
||||||
|
queue: {
|
||||||
|
mode: "interrupt", // global default: queue | interrupt | drop
|
||||||
|
bySurface: {
|
||||||
|
whatsapp: "interrupt",
|
||||||
|
telegram: "interrupt",
|
||||||
|
discord: "queue",
|
||||||
|
webchat: "queue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### `discord` (bot transport)
|
### `discord` (bot transport)
|
||||||
|
|
||||||
Configure the Discord bot by setting the bot token and optional gating:
|
Configure the Discord bot by setting the bot token and optional gating:
|
||||||
@@ -94,6 +114,10 @@ Configure the Discord bot by setting the bot token and optional gating:
|
|||||||
discord: {
|
discord: {
|
||||||
token: "your-bot-token",
|
token: "your-bot-token",
|
||||||
allowFrom: ["discord:1234567890", "*"], // optional DM allowlist (user ids)
|
allowFrom: ["discord:1234567890", "*"], // optional DM allowlist (user ids)
|
||||||
|
guildAllowFrom: {
|
||||||
|
guilds: ["123456789012345678"], // optional guild allowlist (ids)
|
||||||
|
users: ["987654321098765432"] // optional user allowlist (ids)
|
||||||
|
},
|
||||||
requireMention: true, // require @bot mentions in guilds
|
requireMention: true, // require @bot mentions in guilds
|
||||||
mediaMaxMb: 8 // clamp inbound media size
|
mediaMaxMb: 8 // clamp inbound media size
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
|||||||
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session.
|
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session.
|
||||||
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default; disable with `discord.requireMention = false`.
|
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default; disable with `discord.requireMention = false`.
|
||||||
7. Optional DM allowlist: reuse `discord.allowFrom` with user ids (`1234567890` or `discord:1234567890`). Use `"*"` to allow all DMs.
|
7. Optional DM allowlist: reuse `discord.allowFrom` with user ids (`1234567890` or `discord:1234567890`). Use `"*"` to allow all DMs.
|
||||||
|
8. Optional guild allowlist: set `discord.guildAllowFrom` with `guilds` and/or `users` to gate who can invoke the bot in servers.
|
||||||
|
|
||||||
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
||||||
|
|
||||||
@@ -38,6 +39,10 @@ Note: Discord does not provide a simple username → id lookup without extra gui
|
|||||||
discord: {
|
discord: {
|
||||||
token: "abc.123",
|
token: "abc.123",
|
||||||
allowFrom: ["123456789012345678"],
|
allowFrom: ["123456789012345678"],
|
||||||
|
guildAllowFrom: {
|
||||||
|
guilds: ["123456789012345678"],
|
||||||
|
users: ["987654321098765432"]
|
||||||
|
},
|
||||||
requireMention: true,
|
requireMention: true,
|
||||||
mediaMaxMb: 8
|
mediaMaxMb: 8
|
||||||
}
|
}
|
||||||
@@ -45,6 +50,7 @@ Note: Discord does not provide a simple username → id lookup without extra gui
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `allowFrom`: DM allowlist (user ids). Omit or set to `["*"]` to allow any DM sender.
|
- `allowFrom`: DM allowlist (user ids). Omit or set to `["*"]` to allow any DM sender.
|
||||||
|
- `guildAllowFrom`: Optional allowlist for guild messages. Set `guilds` and/or `users` (ids). When both are set, both must match.
|
||||||
- `requireMention`: when `true`, messages in guild channels must mention the bot.
|
- `requireMention`: when `true`, messages in guild channels must mention the bot.
|
||||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,33 @@ We now serialize command-based auto-replies (WhatsApp Web listener) through a ti
|
|||||||
- When verbose logging is enabled, queued commands emit a short notice if they waited more than ~2s before starting.
|
- When verbose logging is enabled, queued commands emit a short notice if they waited more than ~2s before starting.
|
||||||
- Typing indicators (`onReplyStart`) still fire immediately on enqueue so user experience is unchanged while we wait our turn.
|
- Typing indicators (`onReplyStart`) still fire immediately on enqueue so user experience is unchanged while we wait our turn.
|
||||||
|
|
||||||
|
## Queue modes (per surface)
|
||||||
|
Inbound messages can either queue or interrupt when a run is already active:
|
||||||
|
- `queue`: serialize per session; if the agent is streaming, the new message is appended to the current run.
|
||||||
|
- `interrupt`: abort the active run for that session, then run the newest message.
|
||||||
|
- `drop`: ignore the message if the session lane is busy.
|
||||||
|
|
||||||
|
Defaults (when unset in config):
|
||||||
|
- WhatsApp + Telegram → `interrupt`
|
||||||
|
- Discord + WebChat → `queue`
|
||||||
|
|
||||||
|
Configure globally or per surface via `routing.queue`:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
routing: {
|
||||||
|
queue: {
|
||||||
|
mode: "interrupt",
|
||||||
|
bySurface: { discord: "queue", telegram: "interrupt" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Per-session overrides
|
||||||
|
- `/queue <mode>` as a standalone command stores the mode for the current session.
|
||||||
|
- `/queue <mode>` embedded in a message applies **once** (no persistence).
|
||||||
|
|
||||||
## Scope and guarantees
|
## Scope and guarantees
|
||||||
- Applies only to config-driven command replies; plain text replies are unaffected.
|
- Applies only to config-driven command replies; plain text replies are unaffected.
|
||||||
- Default lane (`main`) is process-wide for inbound + main heartbeats; set `agent.maxConcurrent` to allow multiple sessions in parallel.
|
- Default lane (`main`) is process-wide for inbound + main heartbeats; set `agent.maxConcurrent` to allow multiple sessions in parallel.
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export type EmbeddedPiRunResult = {
|
|||||||
type EmbeddedPiQueueHandle = {
|
type EmbeddedPiQueueHandle = {
|
||||||
queueMessage: (text: string) => Promise<void>;
|
queueMessage: (text: string) => Promise<void>;
|
||||||
isStreaming: () => boolean;
|
isStreaming: () => boolean;
|
||||||
|
abort: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTIVE_EMBEDDED_RUNS = new Map<string, EmbeddedPiQueueHandle>();
|
const ACTIVE_EMBEDDED_RUNS = new Map<string, EmbeddedPiQueueHandle>();
|
||||||
@@ -203,6 +204,27 @@ export function queueEmbeddedPiMessage(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function abortEmbeddedPiRun(sessionId: string): boolean {
|
||||||
|
const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId);
|
||||||
|
if (!handle) return false;
|
||||||
|
handle.abort();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmbeddedPiRunActive(sessionId: string): boolean {
|
||||||
|
return ACTIVE_EMBEDDED_RUNS.has(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmbeddedPiRunStreaming(sessionId: string): boolean {
|
||||||
|
const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId);
|
||||||
|
if (!handle) return false;
|
||||||
|
return handle.isStreaming();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEmbeddedSessionLane(key: string) {
|
||||||
|
return resolveSessionLane(key);
|
||||||
|
}
|
||||||
|
|
||||||
function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
|
function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
|
||||||
// pi-agent-core supports "xhigh" too; Clawdis doesn't surface it for now.
|
// pi-agent-core supports "xhigh" too; Clawdis doesn't surface it for now.
|
||||||
if (!level) return "off";
|
if (!level) return "off";
|
||||||
@@ -445,14 +467,19 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
if (prior.length > 0) {
|
if (prior.length > 0) {
|
||||||
session.agent.replaceMessages(prior);
|
session.agent.replaceMessages(prior);
|
||||||
}
|
}
|
||||||
|
let aborted = Boolean(params.abortSignal?.aborted);
|
||||||
|
const abortRun = () => {
|
||||||
|
aborted = true;
|
||||||
|
void session.abort();
|
||||||
|
};
|
||||||
const queueHandle: EmbeddedPiQueueHandle = {
|
const queueHandle: EmbeddedPiQueueHandle = {
|
||||||
queueMessage: async (text: string) => {
|
queueMessage: async (text: string) => {
|
||||||
await session.queueMessage(text);
|
await session.queueMessage(text);
|
||||||
},
|
},
|
||||||
isStreaming: () => session.isStreaming,
|
isStreaming: () => session.isStreaming,
|
||||||
|
abort: abortRun,
|
||||||
};
|
};
|
||||||
ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle);
|
ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle);
|
||||||
let aborted = Boolean(params.abortSignal?.aborted);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
assistantTexts,
|
assistantTexts,
|
||||||
@@ -473,8 +500,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
|
|
||||||
const abortTimer = setTimeout(
|
const abortTimer = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
aborted = true;
|
abortRun();
|
||||||
void session.abort();
|
|
||||||
},
|
},
|
||||||
Math.max(1, params.timeoutMs),
|
Math.max(1, params.timeoutMs),
|
||||||
);
|
);
|
||||||
@@ -482,8 +508,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
let messagesSnapshot: AppMessage[] = [];
|
let messagesSnapshot: AppMessage[] = [];
|
||||||
let sessionIdUsed = session.sessionId;
|
let sessionIdUsed = session.sessionId;
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
aborted = true;
|
abortRun();
|
||||||
void session.abort();
|
|
||||||
};
|
};
|
||||||
if (params.abortSignal) {
|
if (params.abortSignal) {
|
||||||
if (params.abortSignal.aborted) {
|
if (params.abortSignal.aborted) {
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ export type {
|
|||||||
EmbeddedPiRunResult,
|
EmbeddedPiRunResult,
|
||||||
} from "./pi-embedded-runner.js";
|
} from "./pi-embedded-runner.js";
|
||||||
export {
|
export {
|
||||||
|
abortEmbeddedPiRun,
|
||||||
|
isEmbeddedPiRunActive,
|
||||||
|
isEmbeddedPiRunStreaming,
|
||||||
queueEmbeddedPiMessage,
|
queueEmbeddedPiMessage,
|
||||||
|
resolveEmbeddedSessionLane,
|
||||||
runEmbeddedPiAgent,
|
runEmbeddedPiAgent,
|
||||||
} from "./pi-embedded-runner.js";
|
} from "./pi-embedded-runner.js";
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("../agents/pi-embedded.js", () => ({
|
vi.mock("../agents/pi-embedded.js", () => ({
|
||||||
|
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||||
runEmbeddedPiAgent: vi.fn(),
|
runEmbeddedPiAgent: vi.fn(),
|
||||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||||
|
resolveEmbeddedSessionLane: (key: string) =>
|
||||||
|
`session:${key.trim() || "main"}`,
|
||||||
}));
|
}));
|
||||||
vi.mock("../agents/model-catalog.js", () => ({
|
vi.mock("../agents/model-catalog.js", () => ({
|
||||||
loadModelCatalog: vi.fn(),
|
loadModelCatalog: vi.fn(),
|
||||||
@@ -20,6 +23,7 @@ import {
|
|||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import {
|
import {
|
||||||
|
extractQueueDirective,
|
||||||
extractThinkDirective,
|
extractThinkDirective,
|
||||||
extractVerboseDirective,
|
extractVerboseDirective,
|
||||||
getReplyFromConfig,
|
getReplyFromConfig,
|
||||||
@@ -83,6 +87,13 @@ describe("directive parsing", () => {
|
|||||||
expect(res.thinkLevel).toBe("high");
|
expect(res.thinkLevel).toBe("high");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches queue directive", () => {
|
||||||
|
const res = extractQueueDirective("please /queue interrupt now");
|
||||||
|
expect(res.hasDirective).toBe(true);
|
||||||
|
expect(res.queueMode).toBe("interrupt");
|
||||||
|
expect(res.cleaned).toBe("please now");
|
||||||
|
});
|
||||||
|
|
||||||
it("applies inline think and still runs agent content", async () => {
|
it("applies inline think and still runs agent content", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
@@ -142,6 +153,33 @@ describe("directive parsing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("acks queue directive and persists override", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
|
const res = await getReplyFromConfig(
|
||||||
|
{ Body: "/queue interrupt", From: "+1222", To: "+1222" },
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
workspace: path.join(home, "clawd"),
|
||||||
|
},
|
||||||
|
routing: { allowFrom: ["*"] },
|
||||||
|
session: { store: storePath },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
|
expect(text).toMatch(/^⚙️ Queue mode set to interrupt\./);
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const entry = Object.values(store)[0];
|
||||||
|
expect(entry?.queueMode).toBe("interrupt");
|
||||||
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("updates tool verbose during an in-flight run (toggle on)", async () => {
|
it("updates tool verbose during an in-flight run (toggle on)", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import { join } from "node:path";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("../agents/pi-embedded.js", () => ({
|
vi.mock("../agents/pi-embedded.js", () => ({
|
||||||
|
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||||
runEmbeddedPiAgent: vi.fn(),
|
runEmbeddedPiAgent: vi.fn(),
|
||||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||||
|
resolveEmbeddedSessionLane: (key: string) =>
|
||||||
|
`session:${key.trim() || "main"}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
resolveConfiguredModelRef,
|
resolveConfiguredModelRef,
|
||||||
} from "../agents/model-selection.js";
|
} from "../agents/model-selection.js";
|
||||||
import {
|
import {
|
||||||
|
abortEmbeddedPiRun,
|
||||||
queueEmbeddedPiMessage,
|
queueEmbeddedPiMessage,
|
||||||
|
resolveEmbeddedSessionLane,
|
||||||
runEmbeddedPiAgent,
|
runEmbeddedPiAgent,
|
||||||
} from "../agents/pi-embedded.js";
|
} from "../agents/pi-embedded.js";
|
||||||
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||||
@@ -37,6 +39,7 @@ import { logVerbose } from "../globals.js";
|
|||||||
import { buildProviderSummary } from "../infra/provider-summary.js";
|
import { buildProviderSummary } from "../infra/provider-summary.js";
|
||||||
import { triggerClawdisRestart } from "../infra/restart.js";
|
import { triggerClawdisRestart } from "../infra/restart.js";
|
||||||
import { drainSystemEvents } from "../infra/system-events.js";
|
import { drainSystemEvents } from "../infra/system-events.js";
|
||||||
|
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164 } from "../utils.js";
|
||||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||||
@@ -67,6 +70,8 @@ const SYSTEM_MARK = "⚙️";
|
|||||||
const BARE_SESSION_RESET_PROMPT =
|
const BARE_SESSION_RESET_PROMPT =
|
||||||
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning.";
|
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning.";
|
||||||
|
|
||||||
|
type QueueMode = "queue" | "interrupt" | "drop";
|
||||||
|
|
||||||
export function extractThinkDirective(body?: string): {
|
export function extractThinkDirective(body?: string): {
|
||||||
cleaned: string;
|
cleaned: string;
|
||||||
thinkLevel?: ThinkLevel;
|
thinkLevel?: ThinkLevel;
|
||||||
@@ -112,6 +117,36 @@ export function extractVerboseDirective(body?: string): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeQueueMode(raw?: string): QueueMode | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const cleaned = raw.trim().toLowerCase();
|
||||||
|
if (cleaned === "queue" || cleaned === "queued") return "queue";
|
||||||
|
if (cleaned === "interrupt" || cleaned === "interrupts" || cleaned === "abort")
|
||||||
|
return "interrupt";
|
||||||
|
if (cleaned === "drop" || cleaned === "discard") return "drop";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractQueueDirective(body?: string): {
|
||||||
|
cleaned: string;
|
||||||
|
queueMode?: QueueMode;
|
||||||
|
rawMode?: string;
|
||||||
|
hasDirective: boolean;
|
||||||
|
} {
|
||||||
|
if (!body) return { cleaned: "", hasDirective: false };
|
||||||
|
const match = body.match(/(?:^|\s)\/queue(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i);
|
||||||
|
const queueMode = normalizeQueueMode(match?.[1]);
|
||||||
|
const cleaned = match
|
||||||
|
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
|
||||||
|
: body.trim();
|
||||||
|
return {
|
||||||
|
cleaned,
|
||||||
|
queueMode,
|
||||||
|
rawMode: match?.[1],
|
||||||
|
hasDirective: !!match,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isAbortTrigger(text?: string): boolean {
|
function isAbortTrigger(text?: string): boolean {
|
||||||
if (!text) return false;
|
if (!text) return false;
|
||||||
const normalized = text.trim().toLowerCase();
|
const normalized = text.trim().toLowerCase();
|
||||||
@@ -156,9 +191,41 @@ function stripMentions(
|
|||||||
}
|
}
|
||||||
// Generic mention patterns like @123456789 or plain digits
|
// Generic mention patterns like @123456789 or plain digits
|
||||||
result = result.replace(/@[0-9+]{5,}/g, " ");
|
result = result.replace(/@[0-9+]{5,}/g, " ");
|
||||||
|
// Discord-style mentions (<@123> or <@!123>)
|
||||||
|
result = result.replace(/<@!?\d+>/g, " ");
|
||||||
return result.replace(/\s+/g, " ").trim();
|
return result.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultQueueModeForSurface(surface?: string): QueueMode {
|
||||||
|
const normalized = surface?.trim().toLowerCase();
|
||||||
|
if (normalized === "discord") return "queue";
|
||||||
|
if (normalized === "webchat") return "queue";
|
||||||
|
return "interrupt";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQueueMode(params: {
|
||||||
|
cfg: ClawdisConfig;
|
||||||
|
surface?: string;
|
||||||
|
sessionEntry?: SessionEntry;
|
||||||
|
inlineMode?: QueueMode;
|
||||||
|
}): QueueMode {
|
||||||
|
const surfaceKey = params.surface?.trim().toLowerCase();
|
||||||
|
const queueCfg = params.cfg.routing?.queue;
|
||||||
|
const surfaceMode =
|
||||||
|
surfaceKey && queueCfg?.bySurface
|
||||||
|
? (queueCfg.bySurface as Record<string, QueueMode | undefined>)[
|
||||||
|
surfaceKey
|
||||||
|
]
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
params.inlineMode ??
|
||||||
|
params.sessionEntry?.queueMode ??
|
||||||
|
surfaceMode ??
|
||||||
|
queueCfg?.mode ??
|
||||||
|
defaultQueueModeForSurface(surfaceKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getReplyFromConfig(
|
export async function getReplyFromConfig(
|
||||||
ctx: MsgContext,
|
ctx: MsgContext,
|
||||||
opts?: GetReplyOptions,
|
opts?: GetReplyOptions,
|
||||||
@@ -343,6 +410,7 @@ export async function getReplyFromConfig(
|
|||||||
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
|
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
|
||||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||||
|
queueMode: baseEntry?.queueMode,
|
||||||
};
|
};
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
await saveSessionStore(storePath, sessionStore);
|
await saveSessionStore(storePath, sessionStore);
|
||||||
@@ -371,8 +439,14 @@ export async function getReplyFromConfig(
|
|||||||
rawModel: rawModelDirective,
|
rawModel: rawModelDirective,
|
||||||
hasDirective: hasModelDirective,
|
hasDirective: hasModelDirective,
|
||||||
} = extractModelDirective(verboseCleaned);
|
} = extractModelDirective(verboseCleaned);
|
||||||
sessionCtx.Body = modelCleaned;
|
const {
|
||||||
sessionCtx.BodyStripped = modelCleaned;
|
cleaned: queueCleaned,
|
||||||
|
queueMode: inlineQueueMode,
|
||||||
|
rawMode: rawQueueMode,
|
||||||
|
hasDirective: hasQueueDirective,
|
||||||
|
} = extractQueueDirective(modelCleaned);
|
||||||
|
sessionCtx.Body = queueCleaned;
|
||||||
|
sessionCtx.BodyStripped = queueCleaned;
|
||||||
|
|
||||||
const defaultGroupActivation = () => {
|
const defaultGroupActivation = () => {
|
||||||
const requireMention = cfg.routing?.groupChat?.requireMention;
|
const requireMention = cfg.routing?.groupChat?.requireMention;
|
||||||
@@ -457,9 +531,14 @@ export async function getReplyFromConfig(
|
|||||||
DEFAULT_CONTEXT_TOKENS;
|
DEFAULT_CONTEXT_TOKENS;
|
||||||
|
|
||||||
const directiveOnly = (() => {
|
const directiveOnly = (() => {
|
||||||
if (!hasThinkDirective && !hasVerboseDirective && !hasModelDirective)
|
if (
|
||||||
|
!hasThinkDirective &&
|
||||||
|
!hasVerboseDirective &&
|
||||||
|
!hasModelDirective &&
|
||||||
|
!hasQueueDirective
|
||||||
|
)
|
||||||
return false;
|
return false;
|
||||||
const stripped = stripStructuralPrefixes(modelCleaned ?? "");
|
const stripped = stripStructuralPrefixes(queueCleaned ?? "");
|
||||||
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
|
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
|
||||||
return noMentions.length === 0;
|
return noMentions.length === 0;
|
||||||
})();
|
})();
|
||||||
@@ -501,6 +580,12 @@ export async function getReplyFromConfig(
|
|||||||
text: `Unrecognized verbose level "${rawVerboseLevel ?? ""}". Valid levels: off, on.`,
|
text: `Unrecognized verbose level "${rawVerboseLevel ?? ""}". Valid levels: off, on.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (hasQueueDirective && !inlineQueueMode) {
|
||||||
|
cleanupTyping();
|
||||||
|
return {
|
||||||
|
text: `Unrecognized queue mode "${rawQueueMode ?? ""}". Valid modes: queue, interrupt, drop.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let modelSelection:
|
let modelSelection:
|
||||||
| { provider: string; model: string; isDefault: boolean }
|
| { provider: string; model: string; isDefault: boolean }
|
||||||
@@ -543,6 +628,9 @@ export async function getReplyFromConfig(
|
|||||||
sessionEntry.modelOverride = modelSelection.model;
|
sessionEntry.modelOverride = modelSelection.model;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (hasQueueDirective && inlineQueueMode) {
|
||||||
|
sessionEntry.queueMode = inlineQueueMode;
|
||||||
|
}
|
||||||
sessionEntry.updatedAt = Date.now();
|
sessionEntry.updatedAt = Date.now();
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
await saveSessionStore(storePath, sessionStore);
|
await saveSessionStore(storePath, sessionStore);
|
||||||
@@ -571,6 +659,9 @@ export async function getReplyFromConfig(
|
|||||||
: `Model set to ${label}.`,
|
: `Model set to ${label}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (hasQueueDirective && inlineQueueMode) {
|
||||||
|
parts.push(`${SYSTEM_MARK} Queue mode set to ${inlineQueueMode}.`);
|
||||||
|
}
|
||||||
const ack = parts.join(" ").trim();
|
const ack = parts.join(" ").trim();
|
||||||
cleanupTyping();
|
cleanupTyping();
|
||||||
return { text: ack || "OK." };
|
return { text: ack || "OK." };
|
||||||
@@ -626,6 +717,7 @@ export async function getReplyFromConfig(
|
|||||||
await saveSessionStore(storePath, sessionStore);
|
await saveSessionStore(storePath, sessionStore);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const perMessageQueueMode = hasQueueDirective ? inlineQueueMode : undefined;
|
||||||
|
|
||||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||||
const configuredAllowFrom = cfg.routing?.allowFrom;
|
const configuredAllowFrom = cfg.routing?.allowFrom;
|
||||||
@@ -990,7 +1082,35 @@ export async function getReplyFromConfig(
|
|||||||
.trim()
|
.trim()
|
||||||
: queueBodyBase;
|
: queueBodyBase;
|
||||||
|
|
||||||
if (queueEmbeddedPiMessage(sessionIdFinal, queuedBody)) {
|
const resolvedQueueMode = resolveQueueMode({
|
||||||
|
cfg,
|
||||||
|
surface: sessionCtx.Surface,
|
||||||
|
sessionEntry,
|
||||||
|
inlineMode: perMessageQueueMode,
|
||||||
|
});
|
||||||
|
const sessionLaneKey = resolveEmbeddedSessionLane(
|
||||||
|
sessionKey ?? sessionIdFinal,
|
||||||
|
);
|
||||||
|
const laneSize = getQueueSize(sessionLaneKey);
|
||||||
|
if (resolvedQueueMode === "drop" && laneSize > 0) {
|
||||||
|
logVerbose(
|
||||||
|
`Dropping inbound message for ${sessionLaneKey} (queue busy, mode=drop)`,
|
||||||
|
);
|
||||||
|
cleanupTyping();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (resolvedQueueMode === "interrupt" && laneSize > 0) {
|
||||||
|
const cleared = clearCommandLane(sessionLaneKey);
|
||||||
|
const aborted = abortEmbeddedPiRun(sessionIdFinal);
|
||||||
|
logVerbose(
|
||||||
|
`Interrupting ${sessionLaneKey} (cleared ${cleared}, aborted=${aborted})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
resolvedQueueMode === "queue" &&
|
||||||
|
queueEmbeddedPiMessage(sessionIdFinal, queuedBody)
|
||||||
|
) {
|
||||||
if (sessionEntry && sessionStore && sessionKey) {
|
if (sessionEntry && sessionStore && sessionKey) {
|
||||||
sessionEntry.updatedAt = Date.now();
|
sessionEntry.updatedAt = Date.now();
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import {
|
|||||||
} from "vitest";
|
} from "vitest";
|
||||||
|
|
||||||
vi.mock("../agents/pi-embedded.js", () => ({
|
vi.mock("../agents/pi-embedded.js", () => ({
|
||||||
|
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||||
runEmbeddedPiAgent: vi.fn(),
|
runEmbeddedPiAgent: vi.fn(),
|
||||||
|
resolveEmbeddedSessionLane: (key: string) =>
|
||||||
|
`session:${key.trim() || "main"}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
|
|||||||
@@ -139,10 +139,23 @@ export type TelegramConfig = {
|
|||||||
export type DiscordConfig = {
|
export type DiscordConfig = {
|
||||||
token?: string;
|
token?: string;
|
||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
|
guildAllowFrom?: {
|
||||||
|
guilds?: Array<string | number>;
|
||||||
|
users?: Array<string | number>;
|
||||||
|
};
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QueueMode = "queue" | "interrupt" | "drop";
|
||||||
|
|
||||||
|
export type QueueModeBySurface = {
|
||||||
|
whatsapp?: QueueMode;
|
||||||
|
telegram?: QueueMode;
|
||||||
|
discord?: QueueMode;
|
||||||
|
webchat?: QueueMode;
|
||||||
|
};
|
||||||
|
|
||||||
export type GroupChatConfig = {
|
export type GroupChatConfig = {
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
mentionPatterns?: string[];
|
mentionPatterns?: string[];
|
||||||
@@ -157,6 +170,10 @@ export type RoutingConfig = {
|
|||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
};
|
};
|
||||||
groupChat?: GroupChatConfig;
|
groupChat?: GroupChatConfig;
|
||||||
|
queue?: {
|
||||||
|
mode?: QueueMode;
|
||||||
|
bySurface?: QueueModeBySurface;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessagesConfig = {
|
export type MessagesConfig = {
|
||||||
@@ -437,6 +454,21 @@ const GroupChatSchema = z
|
|||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
const QueueModeSchema = z.union([
|
||||||
|
z.literal("queue"),
|
||||||
|
z.literal("interrupt"),
|
||||||
|
z.literal("drop"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const QueueModeBySurfaceSchema = z
|
||||||
|
.object({
|
||||||
|
whatsapp: QueueModeSchema.optional(),
|
||||||
|
telegram: QueueModeSchema.optional(),
|
||||||
|
discord: QueueModeSchema.optional(),
|
||||||
|
webchat: QueueModeSchema.optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
const TranscribeAudioSchema = z
|
const TranscribeAudioSchema = z
|
||||||
.object({
|
.object({
|
||||||
command: z.array(z.string()),
|
command: z.array(z.string()),
|
||||||
@@ -498,6 +530,12 @@ const RoutingSchema = z
|
|||||||
allowFrom: z.array(z.string()).optional(),
|
allowFrom: z.array(z.string()).optional(),
|
||||||
groupChat: GroupChatSchema,
|
groupChat: GroupChatSchema,
|
||||||
transcribeAudio: TranscribeAudioSchema,
|
transcribeAudio: TranscribeAudioSchema,
|
||||||
|
queue: z
|
||||||
|
.object({
|
||||||
|
mode: QueueModeSchema.optional(),
|
||||||
|
bySurface: QueueModeBySurfaceSchema,
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
@@ -698,6 +736,12 @@ const ClawdisSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
token: z.string().optional(),
|
token: z.string().optional(),
|
||||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
guildAllowFrom: z
|
||||||
|
.object({
|
||||||
|
guilds: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type SessionEntry = {
|
|||||||
modelOverride?: string;
|
modelOverride?: string;
|
||||||
groupActivation?: "mention" | "always";
|
groupActivation?: "mention" | "always";
|
||||||
groupActivationNeedsSystemIntro?: boolean;
|
groupActivationNeedsSystemIntro?: boolean;
|
||||||
|
queueMode?: "queue" | "interrupt" | "drop";
|
||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
@@ -132,6 +133,7 @@ export async function updateLastRoute(params: {
|
|||||||
verboseLevel: existing?.verboseLevel,
|
verboseLevel: existing?.verboseLevel,
|
||||||
providerOverride: existing?.providerOverride,
|
providerOverride: existing?.providerOverride,
|
||||||
modelOverride: existing?.modelOverride,
|
modelOverride: existing?.modelOverride,
|
||||||
|
queueMode: existing?.queueMode,
|
||||||
inputTokens: existing?.inputTokens,
|
inputTokens: existing?.inputTokens,
|
||||||
outputTokens: existing?.outputTokens,
|
outputTokens: existing?.outputTokens,
|
||||||
totalTokens: existing?.totalTokens,
|
totalTokens: existing?.totalTokens,
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import type { ClawdisConfig } from "../config/config.js";
|
|||||||
import type { CronJob } from "./types.js";
|
import type { CronJob } from "./types.js";
|
||||||
|
|
||||||
vi.mock("../agents/pi-embedded.js", () => ({
|
vi.mock("../agents/pi-embedded.js", () => ({
|
||||||
|
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||||
runEmbeddedPiAgent: vi.fn(),
|
runEmbeddedPiAgent: vi.fn(),
|
||||||
|
resolveEmbeddedSessionLane: (key: string) =>
|
||||||
|
`session:${key.trim() || "main"}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export type MonitorDiscordOpts = {
|
|||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
|
guildAllowFrom?: {
|
||||||
|
guilds?: Array<string | number>;
|
||||||
|
users?: Array<string | number>;
|
||||||
|
};
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
};
|
};
|
||||||
@@ -55,6 +59,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const allowFrom = opts.allowFrom ?? cfg.discord?.allowFrom;
|
const allowFrom = opts.allowFrom ?? cfg.discord?.allowFrom;
|
||||||
|
const guildAllowFrom = opts.guildAllowFrom ?? cfg.discord?.guildAllowFrom;
|
||||||
const requireMention =
|
const requireMention =
|
||||||
opts.requireMention ?? cfg.discord?.requireMention ?? true;
|
opts.requireMention ?? cfg.discord?.requireMention ?? true;
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
@@ -86,9 +91,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
if (!message.author) return;
|
if (!message.author) return;
|
||||||
|
|
||||||
const isDirectMessage = !message.guild;
|
const isDirectMessage = !message.guild;
|
||||||
|
const botId = client.user?.id;
|
||||||
|
const wasMentioned =
|
||||||
|
!isDirectMessage && Boolean(botId && message.mentions.has(botId));
|
||||||
if (!isDirectMessage && requireMention) {
|
if (!isDirectMessage && requireMention) {
|
||||||
const botId = client.user?.id;
|
if (botId && !wasMentioned) {
|
||||||
if (botId && !message.mentions.has(botId)) {
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
channelId: message.channelId,
|
channelId: message.channelId,
|
||||||
@@ -100,6 +107,31 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isDirectMessage && guildAllowFrom) {
|
||||||
|
const guilds = normalizeDiscordAllowList(guildAllowFrom.guilds, [
|
||||||
|
"guild:",
|
||||||
|
]);
|
||||||
|
const users = normalizeDiscordAllowList(guildAllowFrom.users, [
|
||||||
|
"discord:",
|
||||||
|
"user:",
|
||||||
|
]);
|
||||||
|
if (guilds || users) {
|
||||||
|
const guildId = message.guild?.id ?? "";
|
||||||
|
const userId = message.author.id;
|
||||||
|
const guildOk =
|
||||||
|
!guilds ||
|
||||||
|
guilds.allowAll ||
|
||||||
|
(guildId && guilds.ids.has(guildId));
|
||||||
|
const userOk = !users || users.allowAll || users.ids.has(userId);
|
||||||
|
if (!guildOk || !userOk) {
|
||||||
|
logVerbose(
|
||||||
|
`Blocked discord guild sender ${userId} (guild ${guildId || "unknown"}) not in guildAllowFrom`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||||
const allowed = allowFrom
|
const allowed = allowFrom
|
||||||
.map((entry) => String(entry).trim())
|
.map((entry) => String(entry).trim())
|
||||||
@@ -155,6 +187,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
? message.channel.name
|
? message.channel.name
|
||||||
: undefined,
|
: undefined,
|
||||||
Surface: "discord" as const,
|
Surface: "discord" as const,
|
||||||
|
WasMentioned: wasMentioned,
|
||||||
MessageSid: message.id,
|
MessageSid: message.id,
|
||||||
Timestamp: message.createdTimestamp,
|
Timestamp: message.createdTimestamp,
|
||||||
MediaPath: media?.path,
|
MediaPath: media?.path,
|
||||||
@@ -276,6 +309,27 @@ function buildGuildLabel(message: import("discord.js").Message) {
|
|||||||
return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`;
|
return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDiscordAllowList(
|
||||||
|
raw: Array<string | number> | undefined,
|
||||||
|
prefixes: string[],
|
||||||
|
): { allowAll: boolean; ids: Set<string> } | null {
|
||||||
|
if (!raw || raw.length === 0) return null;
|
||||||
|
const cleaned = raw
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((entry) => {
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
if (entry.toLowerCase().startsWith(prefix)) {
|
||||||
|
return entry.slice(prefix.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
const allowAll = cleaned.includes("*");
|
||||||
|
const ids = new Set(cleaned.filter((entry) => entry !== "*"));
|
||||||
|
return { allowAll, ids };
|
||||||
|
}
|
||||||
|
|
||||||
async function sendTyping(message: Message) {
|
async function sendTyping(message: Message) {
|
||||||
try {
|
try {
|
||||||
const channel = message.channel;
|
const channel = message.channel;
|
||||||
|
|||||||
@@ -2014,6 +2014,7 @@ export async function startGatewayServer(
|
|||||||
runtime: discordRuntimeEnv,
|
runtime: discordRuntimeEnv,
|
||||||
abortSignal: discordAbort.signal,
|
abortSignal: discordAbort.signal,
|
||||||
allowFrom: cfg.discord?.allowFrom,
|
allowFrom: cfg.discord?.allowFrom,
|
||||||
|
guildAllowFrom: cfg.discord?.guildAllowFrom,
|
||||||
requireMention: cfg.discord?.requireMention,
|
requireMention: cfg.discord?.requireMention,
|
||||||
mediaMaxMb: cfg.discord?.mediaMaxMb,
|
mediaMaxMb: cfg.discord?.mediaMaxMb,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -122,3 +122,12 @@ export function getTotalQueueSize() {
|
|||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearCommandLane(lane = "main") {
|
||||||
|
const cleaned = lane.trim() || "main";
|
||||||
|
const state = lanes.get(cleaned);
|
||||||
|
if (!state) return 0;
|
||||||
|
const removed = state.queue.length;
|
||||||
|
state.queue.length = 0;
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import sharp from "sharp";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("../agents/pi-embedded.js", () => ({
|
vi.mock("../agents/pi-embedded.js", () => ({
|
||||||
|
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||||
runEmbeddedPiAgent: vi.fn(),
|
runEmbeddedPiAgent: vi.fn(),
|
||||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||||
|
resolveEmbeddedSessionLane: (key: string) =>
|
||||||
|
`session:${key.trim() || "main"}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user