feat: mirror delivered outbound messages (#1031)

Co-authored-by: T Savo <TSavo@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-17 01:48:02 +00:00
parent 3fb699a84b
commit fdaeada3ec
26 changed files with 697 additions and 29 deletions

View File

@@ -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.

View File

@@ -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 };
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 = {