feat: mirror delivered outbound messages (#1031)
Co-authored-by: T Savo <TSavo@users.noreply.github.com>
This commit is contained in:
@@ -80,6 +80,7 @@ export function createClawdbotTools(options?: {
|
||||
}),
|
||||
createMessageTool({
|
||||
agentAccountId: options?.agentAccountId,
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
|
||||
78
src/agents/tools/message-tool.test.ts
Normal file
78
src/agents/tools/message-tool.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
|
||||
import { createMessageTool } from "./message-tool.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runMessageAction: vi.fn(),
|
||||
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/outbound/message-action-runner.js", () => ({
|
||||
runMessageAction: mocks.runMessageAction,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||
"../../config/sessions.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
|
||||
};
|
||||
});
|
||||
|
||||
describe("message tool mirroring", () => {
|
||||
it("mirrors media filename for plugin-handled sends", async () => {
|
||||
mocks.appendAssistantMessageToSessionTranscript.mockClear();
|
||||
mocks.runMessageAction.mockResolvedValue({
|
||||
kind: "send",
|
||||
action: "send",
|
||||
channel: "telegram",
|
||||
handledBy: "plugin",
|
||||
payload: {},
|
||||
dryRun: false,
|
||||
} satisfies MessageActionRunResult);
|
||||
|
||||
const tool = createMessageTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
config: {} as never,
|
||||
});
|
||||
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
to: "telegram:123",
|
||||
message: "",
|
||||
media: "https://example.com/files/report.pdf?sig=1",
|
||||
});
|
||||
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "report.pdf" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mirror on dry-run", async () => {
|
||||
mocks.appendAssistantMessageToSessionTranscript.mockClear();
|
||||
mocks.runMessageAction.mockResolvedValue({
|
||||
kind: "send",
|
||||
action: "send",
|
||||
channel: "telegram",
|
||||
handledBy: "plugin",
|
||||
payload: {},
|
||||
dryRun: true,
|
||||
} satisfies MessageActionRunResult);
|
||||
|
||||
const tool = createMessageTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
config: {} as never,
|
||||
});
|
||||
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
to: "telegram:123",
|
||||
message: "hi",
|
||||
});
|
||||
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,13 @@ import {
|
||||
} from "../../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
appendAssistantMessageToSessionTranscript,
|
||||
resolveMirroredTranscriptText,
|
||||
} from "../../config/sessions.js";
|
||||
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
|
||||
import { runMessageAction } from "../../infra/outbound/message-action-runner.js";
|
||||
import { resolveSessionAgentId } from "../agent-scope.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { stringEnum } from "../schema/typebox.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
@@ -119,6 +124,7 @@ const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
|
||||
|
||||
type MessageToolOptions = {
|
||||
agentAccountId?: string;
|
||||
agentSessionKey?: string;
|
||||
config?: ClawdbotConfig;
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string;
|
||||
@@ -187,8 +193,36 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
defaultAccountId: accountId ?? undefined,
|
||||
gateway,
|
||||
toolContext,
|
||||
sessionKey: options?.agentSessionKey,
|
||||
agentId: options?.agentSessionKey
|
||||
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (
|
||||
action === "send" &&
|
||||
options?.agentSessionKey &&
|
||||
!result.dryRun &&
|
||||
result.handledBy === "plugin"
|
||||
) {
|
||||
const mediaUrl = typeof params.media === "string" ? params.media : undefined;
|
||||
const mirrorText = resolveMirroredTranscriptText({
|
||||
text: typeof params.message === "string" ? params.message : undefined,
|
||||
mediaUrls: mediaUrl ? [mediaUrl] : undefined,
|
||||
});
|
||||
if (mirrorText) {
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey: options.agentSessionKey,
|
||||
config: cfg,
|
||||
});
|
||||
await appendAssistantMessageToSessionTranscript({
|
||||
agentId,
|
||||
sessionKey: options.agentSessionKey,
|
||||
text: mirrorText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.toolResult) return result.toolResult;
|
||||
return jsonResult(result.payload);
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
|
||||
sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
|
||||
sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })),
|
||||
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1", toJid: "jid" })),
|
||||
deliverOutboundPayloads: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../discord/send.js", () => ({
|
||||
@@ -37,12 +38,25 @@ vi.mock("../../telegram/send.js", () => ({
|
||||
vi.mock("../../web/outbound.js", () => ({
|
||||
sendMessageWhatsApp: mocks.sendMessageWhatsApp,
|
||||
}));
|
||||
vi.mock("../../infra/outbound/deliver.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
|
||||
"../../infra/outbound/deliver.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
|
||||
};
|
||||
});
|
||||
const actualDeliver = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
|
||||
"../../infra/outbound/deliver.js",
|
||||
);
|
||||
|
||||
const { routeReply } = await import("./route-reply.js");
|
||||
|
||||
describe("routeReply", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
mocks.deliverOutboundPayloads.mockImplementation(actualDeliver.deliverOutboundPayloads);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -261,6 +275,25 @@ describe("routeReply", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes mirror data when sessionKey is set", async () => {
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
sessionKey: "agent:main:main",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mirror: expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
text: "hi",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
||||
|
||||
@@ -113,7 +113,16 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
replyToId: replyToId ?? null,
|
||||
threadId: threadId ?? null,
|
||||
abortSignal,
|
||||
mirror: params.sessionKey
|
||||
? {
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
|
||||
text,
|
||||
mediaUrls,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const last = results.at(-1);
|
||||
return { ok: true, messageId: last?.messageId };
|
||||
} catch (err) {
|
||||
|
||||
@@ -87,7 +87,13 @@ export function registerCronAddCommand(cron: Command) {
|
||||
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
|
||||
)
|
||||
.option("--best-effort-deliver", "Do not fail the job if delivery fails", false)
|
||||
.option("--post-prefix <prefix>", "Prefix for summary system event", "Cron")
|
||||
.option("--post-prefix <prefix>", "Prefix for main-session post", "Cron")
|
||||
.option(
|
||||
"--post-mode <mode>",
|
||||
"What to post back to main for isolated jobs (summary|full)",
|
||||
"summary",
|
||||
)
|
||||
.option("--post-max-chars <n>", "Max chars when --post-mode=full (default 8000)", "8000")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts: GatewayRpcOpts & Record<string, unknown>) => {
|
||||
try {
|
||||
@@ -174,6 +180,14 @@ export function registerCronAddCommand(cron: Command) {
|
||||
typeof opts.postPrefix === "string" && opts.postPrefix.trim()
|
||||
? opts.postPrefix.trim()
|
||||
: "Cron",
|
||||
postToMainMode:
|
||||
opts.postMode === "full" || opts.postMode === "summary"
|
||||
? opts.postMode
|
||||
: undefined,
|
||||
postToMainMaxChars:
|
||||
typeof opts.postMaxChars === "string" && /^\d+$/.test(opts.postMaxChars)
|
||||
? Number.parseInt(opts.postMaxChars, 10)
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "./sessions/paths.js";
|
||||
export * from "./sessions/session-key.js";
|
||||
export * from "./sessions/store.js";
|
||||
export * from "./sessions/types.js";
|
||||
export * from "./sessions/transcript.js";
|
||||
|
||||
114
src/config/sessions/transcript.test.ts
Normal file
114
src/config/sessions/transcript.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
appendAssistantMessageToSessionTranscript,
|
||||
resolveMirroredTranscriptText,
|
||||
} from "./transcript.js";
|
||||
|
||||
describe("resolveMirroredTranscriptText", () => {
|
||||
it("prefers media filenames over text", () => {
|
||||
const result = resolveMirroredTranscriptText({
|
||||
text: "caption here",
|
||||
mediaUrls: ["https://example.com/files/report.pdf?sig=123"],
|
||||
});
|
||||
expect(result).toBe("report.pdf");
|
||||
});
|
||||
|
||||
it("returns trimmed text when no media", () => {
|
||||
const result = resolveMirroredTranscriptText({ text: " hello " });
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendAssistantMessageToSessionTranscript", () => {
|
||||
let tempDir: string;
|
||||
let storePath: string;
|
||||
let sessionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-test-"));
|
||||
sessionsDir = path.join(tempDir, "agents", "main", "sessions");
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
storePath = path.join(sessionsDir, "sessions.json");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns error for missing sessionKey", async () => {
|
||||
const result = await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey: "",
|
||||
text: "test",
|
||||
storePath,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("missing sessionKey");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns error for empty text", async () => {
|
||||
const result = await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey: "test-session",
|
||||
text: " ",
|
||||
storePath,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("empty text");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns error for unknown sessionKey", async () => {
|
||||
fs.writeFileSync(storePath, JSON.stringify({}), "utf-8");
|
||||
const result = await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey: "nonexistent",
|
||||
text: "test message",
|
||||
storePath,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toContain("unknown sessionKey");
|
||||
}
|
||||
});
|
||||
|
||||
it("creates transcript file and appends message for valid session", async () => {
|
||||
const sessionId = "test-session-id";
|
||||
const sessionKey = "test-session";
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
chatType: "direct",
|
||||
channel: "discord",
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(storePath, JSON.stringify(store), "utf-8");
|
||||
|
||||
const result = await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey,
|
||||
text: "Hello from delivery mirror!",
|
||||
storePath,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(fs.existsSync(result.sessionFile)).toBe(true);
|
||||
|
||||
const lines = fs.readFileSync(result.sessionFile, "utf-8").trim().split("\n");
|
||||
expect(lines.length).toBe(2); // header + message
|
||||
|
||||
const header = JSON.parse(lines[0]);
|
||||
expect(header.type).toBe("session");
|
||||
expect(header.id).toBe(sessionId);
|
||||
|
||||
const messageLine = JSON.parse(lines[1]);
|
||||
expect(messageLine.type).toBe("message");
|
||||
expect(messageLine.message.role).toBe("assistant");
|
||||
expect(messageLine.message.content[0].type).toBe("text");
|
||||
expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!");
|
||||
}
|
||||
});
|
||||
});
|
||||
131
src/config/sessions/transcript.ts
Normal file
131
src/config/sessions/transcript.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import type { SessionEntry } from "./types.js";
|
||||
import { loadSessionStore, updateSessionStore } from "./store.js";
|
||||
import { resolveDefaultSessionStorePath, resolveSessionTranscriptPath } from "./paths.js";
|
||||
|
||||
function stripQuery(value: string): string {
|
||||
const noHash = value.split("#")[0] ?? value;
|
||||
return noHash.split("?")[0] ?? noHash;
|
||||
}
|
||||
|
||||
function extractFileNameFromMediaUrl(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const cleaned = stripQuery(trimmed);
|
||||
try {
|
||||
const parsed = new URL(cleaned);
|
||||
const base = path.basename(parsed.pathname);
|
||||
if (!base) return null;
|
||||
try {
|
||||
return decodeURIComponent(base);
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
} catch {
|
||||
const base = path.basename(cleaned);
|
||||
if (!base || base === "/" || base === ".") return null;
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMirroredTranscriptText(params: {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
}): string | null {
|
||||
const mediaUrls = params.mediaUrls?.filter((url) => url && url.trim()) ?? [];
|
||||
if (mediaUrls.length > 0) {
|
||||
const names = mediaUrls
|
||||
.map((url) => extractFileNameFromMediaUrl(url))
|
||||
.filter((name): name is string => Boolean(name && name.trim()));
|
||||
if (names.length > 0) return names.join(", ");
|
||||
return "media";
|
||||
}
|
||||
|
||||
const text = params.text ?? "";
|
||||
const trimmed = text.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
async function ensureSessionHeader(params: {
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
}): Promise<void> {
|
||||
if (fs.existsSync(params.sessionFile)) return;
|
||||
await fs.promises.mkdir(path.dirname(params.sessionFile), { recursive: true });
|
||||
const header = {
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id: params.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
await fs.promises.writeFile(params.sessionFile, `${JSON.stringify(header)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
export async function appendAssistantMessageToSessionTranscript(params: {
|
||||
agentId?: string;
|
||||
sessionKey: string;
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
/** Optional override for store path (mostly for tests). */
|
||||
storePath?: string;
|
||||
}): Promise<{ ok: true; sessionFile: string } | { ok: false; reason: string }> {
|
||||
const sessionKey = params.sessionKey.trim();
|
||||
if (!sessionKey) return { ok: false, reason: "missing sessionKey" };
|
||||
|
||||
const mirrorText = resolveMirroredTranscriptText({
|
||||
text: params.text,
|
||||
mediaUrls: params.mediaUrls,
|
||||
});
|
||||
if (!mirrorText) return { ok: false, reason: "empty text" };
|
||||
|
||||
const storePath = params.storePath ?? resolveDefaultSessionStorePath(params.agentId);
|
||||
const store = loadSessionStore(storePath, { skipCache: true });
|
||||
const entry = store[sessionKey] as SessionEntry | undefined;
|
||||
if (!entry?.sessionId) return { ok: false, reason: `unknown sessionKey: ${sessionKey}` };
|
||||
|
||||
const sessionFile =
|
||||
entry.sessionFile?.trim() || resolveSessionTranscriptPath(entry.sessionId, params.agentId);
|
||||
|
||||
await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId });
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: mirrorText }],
|
||||
api: "openai-responses",
|
||||
provider: "clawdbot",
|
||||
model: "delivery-mirror",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
if (!entry.sessionFile || entry.sessionFile !== sessionFile) {
|
||||
await updateSessionStore(storePath, (current) => {
|
||||
current[sessionKey] = {
|
||||
...entry,
|
||||
sessionFile,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true, sessionFile };
|
||||
}
|
||||
@@ -25,6 +25,14 @@ export function pickSummaryFromPayloads(payloads: Array<{ text?: string | undefi
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function pickLastNonEmptyTextFromPayloads(payloads: Array<{ text?: string | undefined }>) {
|
||||
for (let i = payloads.length - 1; i >= 0; i--) {
|
||||
const clean = (payloads[i]?.text ?? "").trim();
|
||||
if (clean) return clean;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all payloads are just heartbeat ack responses (HEARTBEAT_OK).
|
||||
* Returns true if delivery should be skipped because there's no real content.
|
||||
|
||||
@@ -41,6 +41,7 @@ import type { CronJob } from "../types.js";
|
||||
import { resolveDeliveryTarget } from "./delivery-target.js";
|
||||
import {
|
||||
isHeartbeatOnlyResponse,
|
||||
pickLastNonEmptyTextFromPayloads,
|
||||
pickSummaryFromOutput,
|
||||
pickSummaryFromPayloads,
|
||||
resolveHeartbeatAckMaxChars,
|
||||
@@ -50,6 +51,8 @@ import { resolveCronSession } from "./session.js";
|
||||
export type RunCronAgentTurnResult = {
|
||||
status: "ok" | "error" | "skipped";
|
||||
summary?: string;
|
||||
/** Last non-empty agent text output (not truncated). */
|
||||
outputText?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@@ -333,6 +336,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
}
|
||||
const firstText = payloads[0]?.text ?? "";
|
||||
const summary = pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText);
|
||||
const outputText = pickLastNonEmptyTextFromPayloads(payloads);
|
||||
|
||||
// Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content).
|
||||
const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg);
|
||||
@@ -346,12 +350,14 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
return {
|
||||
status: "error",
|
||||
summary,
|
||||
outputText,
|
||||
error: reason,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
summary: `Delivery skipped (${reason}).`,
|
||||
outputText,
|
||||
};
|
||||
}
|
||||
try {
|
||||
@@ -366,11 +372,11 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
});
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver) {
|
||||
return { status: "error", summary, error: String(err) };
|
||||
return { status: "error", summary, outputText, error: String(err) };
|
||||
}
|
||||
return { status: "ok", summary };
|
||||
return { status: "ok", summary, outputText };
|
||||
}
|
||||
}
|
||||
|
||||
return { status: "ok", summary };
|
||||
return { status: "ok", summary, outputText };
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ export type CronServiceDeps = {
|
||||
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
|
||||
status: "ok" | "error" | "skipped";
|
||||
summary?: string;
|
||||
/** Last non-empty agent text output (not truncated). */
|
||||
outputText?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
onEvent?: (evt: CronEvent) => void;
|
||||
|
||||
@@ -66,7 +66,12 @@ export async function executeJob(
|
||||
|
||||
let deleted = false;
|
||||
|
||||
const finish = async (status: "ok" | "error" | "skipped", err?: string, summary?: string) => {
|
||||
const finish = async (
|
||||
status: "ok" | "error" | "skipped",
|
||||
err?: string,
|
||||
summary?: string,
|
||||
outputText?: string,
|
||||
) => {
|
||||
const endedAt = state.deps.nowMs();
|
||||
job.state.runningAtMs = undefined;
|
||||
job.state.lastRunAtMs = startedAt;
|
||||
@@ -108,7 +113,19 @@ export async function executeJob(
|
||||
|
||||
if (job.sessionTarget === "isolated") {
|
||||
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
|
||||
const body = (summary ?? err ?? status).trim();
|
||||
const mode = job.isolation?.postToMainMode ?? "summary";
|
||||
|
||||
let body = (summary ?? err ?? status).trim();
|
||||
if (mode === "full") {
|
||||
// Prefer full agent output if available; fall back to summary.
|
||||
const maxCharsRaw = job.isolation?.postToMainMaxChars;
|
||||
const maxChars = Number.isFinite(maxCharsRaw) ? Math.max(0, maxCharsRaw as number) : 8000;
|
||||
const fullText = (outputText ?? "").trim();
|
||||
if (fullText) {
|
||||
body = fullText.length > maxChars ? `${fullText.slice(0, maxChars)}…` : fullText;
|
||||
}
|
||||
}
|
||||
|
||||
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
|
||||
state.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`, {
|
||||
agentId: job.agentId,
|
||||
@@ -182,9 +199,10 @@ export async function executeJob(
|
||||
job,
|
||||
message: job.payload.message,
|
||||
});
|
||||
if (res.status === "ok") await finish("ok", undefined, res.summary);
|
||||
else if (res.status === "skipped") await finish("skipped", undefined, res.summary);
|
||||
else await finish("error", res.error ?? "cron job failed", res.summary);
|
||||
if (res.status === "ok") await finish("ok", undefined, res.summary, res.outputText);
|
||||
else if (res.status === "skipped")
|
||||
await finish("skipped", undefined, res.summary, res.outputText);
|
||||
else await finish("error", res.error ?? "cron job failed", res.summary, res.outputText);
|
||||
} catch (err) {
|
||||
await finish("error", String(err));
|
||||
} finally {
|
||||
|
||||
@@ -27,6 +27,14 @@ export type CronPayload =
|
||||
|
||||
export type CronIsolation = {
|
||||
postToMainPrefix?: string;
|
||||
/**
|
||||
* What to post back into the main session after an isolated run.
|
||||
* - summary: small status/summary line (default)
|
||||
* - full: the agent's final text output (optionally truncated)
|
||||
*/
|
||||
postToMainMode?: "summary" | "full";
|
||||
/** Max chars when postToMainMode="full". Default: 8000. */
|
||||
postToMainMaxChars?: number;
|
||||
};
|
||||
|
||||
export type CronJobState = {
|
||||
|
||||
@@ -21,6 +21,8 @@ export const SendParamsSchema = Type.Object(
|
||||
gifPlayback: Type.Optional(Type.Boolean()),
|
||||
channel: Type.Optional(Type.String()),
|
||||
accountId: Type.Optional(Type.String()),
|
||||
/** Optional session key for mirroring delivered output back into the transcript. */
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
idempotencyKey: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
|
||||
@@ -55,6 +55,8 @@ export const CronPayloadSchema = Type.Union([
|
||||
export const CronIsolationSchema = Type.Object(
|
||||
{
|
||||
postToMainPrefix: Type.Optional(Type.String()),
|
||||
postToMainMode: Type.Optional(Type.Union([Type.Literal("summary"), Type.Literal("full")])),
|
||||
postToMainMaxChars: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
102
src/gateway/server-methods/send.test.ts
Normal file
102
src/gateway/server-methods/send.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { GatewayRequestContext } from "./types.js";
|
||||
import { sendHandlers } from "./send.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deliverOutboundPayloads: vi.fn(),
|
||||
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
getChannelPlugin: () => ({ outbound: {} }),
|
||||
normalizeChannelId: (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/outbound/targets.js", () => ({
|
||||
resolveOutboundTarget: () => ({ ok: true, to: "resolved" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/outbound/deliver.js", () => ({
|
||||
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||
"../../config/sessions.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
|
||||
};
|
||||
});
|
||||
|
||||
const makeContext = (): GatewayRequestContext =>
|
||||
({
|
||||
dedupe: new Map(),
|
||||
}) as unknown as GatewayRequestContext;
|
||||
|
||||
describe("gateway send mirroring", () => {
|
||||
it("does not mirror when delivery returns no results", async () => {
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
|
||||
const respond = vi.fn();
|
||||
await sendHandlers.send({
|
||||
params: {
|
||||
to: "channel:C1",
|
||||
message: "hi",
|
||||
channel: "slack",
|
||||
idempotencyKey: "idem-1",
|
||||
sessionKey: "agent:main:main",
|
||||
},
|
||||
respond,
|
||||
context: makeContext(),
|
||||
req: { type: "req", id: "1", method: "send" },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mirror: expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("mirrors media filenames when delivery succeeds", async () => {
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m1", channel: "slack" }]);
|
||||
|
||||
const respond = vi.fn();
|
||||
await sendHandlers.send({
|
||||
params: {
|
||||
to: "channel:C1",
|
||||
message: "caption",
|
||||
mediaUrl: "https://example.com/files/report.pdf?sig=1",
|
||||
channel: "slack",
|
||||
idempotencyKey: "idem-2",
|
||||
sessionKey: "agent:main:main",
|
||||
},
|
||||
respond,
|
||||
context: makeContext(),
|
||||
req: { type: "req", id: "1", method: "send" },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mirror: expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/files/report.pdf?sig=1"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import type { OutboundChannel } from "../../infra/outbound/targets.js";
|
||||
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
|
||||
import { normalizePollInput } from "../../polls.js";
|
||||
@@ -37,6 +38,7 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
gifPlayback?: boolean;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
sessionKey?: string;
|
||||
idempotencyKey: string;
|
||||
};
|
||||
const idem = request.idempotencyKey;
|
||||
@@ -94,7 +96,20 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
accountId,
|
||||
payloads: [{ text: message, mediaUrl: request.mediaUrl }],
|
||||
gifPlayback: request.gifPlayback,
|
||||
mirror:
|
||||
typeof request.sessionKey === "string" && request.sessionKey.trim()
|
||||
? {
|
||||
sessionKey: request.sessionKey.trim(),
|
||||
agentId: resolveSessionAgentId({
|
||||
sessionKey: request.sessionKey.trim(),
|
||||
config: cfg,
|
||||
}),
|
||||
text: message,
|
||||
mediaUrls: request.mediaUrl ? [request.mediaUrl] : undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const result = results.at(-1);
|
||||
if (!result) {
|
||||
throw new Error("No delivery result");
|
||||
|
||||
@@ -2,7 +2,22 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { markdownToSignalTextChunks } from "../../signal/format.js";
|
||||
import { deliverOutboundPayloads, normalizeOutboundPayloads } from "./deliver.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||
"../../config/sessions.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
|
||||
};
|
||||
});
|
||||
|
||||
const { deliverOutboundPayloads, normalizeOutboundPayloads } = await import("./deliver.js");
|
||||
|
||||
describe("deliverOutboundPayloads", () => {
|
||||
it("chunks telegram markdown and passes through accountId", async () => {
|
||||
@@ -193,4 +208,29 @@ describe("deliverOutboundPayloads", () => {
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(results).toEqual([{ channel: "whatsapp", messageId: "w2", toJid: "jid" }]);
|
||||
});
|
||||
|
||||
it("mirrors delivered output when mirror options are provided", async () => {
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
|
||||
};
|
||||
mocks.appendAssistantMessageToSessionTranscript.mockClear();
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
payloads: [{ text: "caption", mediaUrl: "https://example.com/files/report.pdf?sig=1" }],
|
||||
deps: { sendTelegram },
|
||||
mirror: {
|
||||
sessionKey: "agent:main:main",
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/files/report.pdf?sig=1"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "report.pdf" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,10 @@ import { sendMessageSignal } from "../../signal/send.js";
|
||||
import type { sendMessageSlack } from "../../slack/send.js";
|
||||
import type { sendMessageTelegram } from "../../telegram/send.js";
|
||||
import type { sendMessageWhatsApp } from "../../web/outbound.js";
|
||||
import {
|
||||
appendAssistantMessageToSessionTranscript,
|
||||
resolveMirroredTranscriptText,
|
||||
} from "../../config/sessions.js";
|
||||
import type { NormalizedOutboundPayload } from "./payloads.js";
|
||||
import { normalizeOutboundPayloads } from "./payloads.js";
|
||||
import type { OutboundChannel } from "./targets.js";
|
||||
@@ -159,6 +163,12 @@ export async function deliverOutboundPayloads(params: {
|
||||
bestEffort?: boolean;
|
||||
onError?: (err: unknown, payload: NormalizedOutboundPayload) => void;
|
||||
onPayload?: (payload: NormalizedOutboundPayload) => void;
|
||||
mirror?: {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
}): Promise<OutboundDeliveryResult[]> {
|
||||
const { cfg, channel, to, payloads } = params;
|
||||
const accountId = params.accountId;
|
||||
@@ -279,5 +289,18 @@ export async function deliverOutboundPayloads(params: {
|
||||
params.onError?.(err, payload);
|
||||
}
|
||||
}
|
||||
if (params.mirror && results.length > 0) {
|
||||
const mirrorText = resolveMirroredTranscriptText({
|
||||
text: params.mirror.text,
|
||||
mediaUrls: params.mirror.mediaUrls,
|
||||
});
|
||||
if (mirrorText) {
|
||||
await appendAssistantMessageToSessionTranscript({
|
||||
agentId: params.mirror.agentId,
|
||||
sessionKey: params.mirror.sessionKey,
|
||||
text: mirrorText,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export type RunMessageActionParams = {
|
||||
toolContext?: ChannelThreadingToolContext;
|
||||
gateway?: MessageActionRunnerGateway;
|
||||
deps?: OutboundSendDeps;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
@@ -265,6 +267,13 @@ export async function runMessageAction(
|
||||
bestEffort: bestEffort ?? undefined,
|
||||
deps: input.deps,
|
||||
gateway,
|
||||
mirror:
|
||||
input.sessionKey && !dryRun
|
||||
? {
|
||||
sessionKey: input.sessionKey,
|
||||
agentId: input.agentId,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -42,6 +42,10 @@ type MessageSendParams = {
|
||||
cfg?: ClawdbotConfig;
|
||||
gateway?: MessageGatewayOptions;
|
||||
idempotencyKey?: string;
|
||||
mirror?: {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MessageSendResult = {
|
||||
@@ -142,6 +146,13 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
||||
gifPlayback: params.gifPlayback,
|
||||
deps: params.deps,
|
||||
bestEffort: params.bestEffort,
|
||||
mirror: params.mirror
|
||||
? {
|
||||
...params.mirror,
|
||||
text: params.content,
|
||||
mediaUrls: params.mediaUrl ? [params.mediaUrl] : undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -165,6 +176,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
||||
gifPlayback: params.gifPlayback,
|
||||
accountId: params.accountId,
|
||||
channel,
|
||||
sessionKey: params.mirror?.sessionKey,
|
||||
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),
|
||||
},
|
||||
timeoutMs: gateway.timeoutMs,
|
||||
|
||||
Reference in New Issue
Block a user