Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,10 @@ describe("SwarmCoordinatorService", () => {
sessionId: "sess-validated",
label: "build-site",
status: "completed",
completionSummary: "deployed",
// The verdict exists ONLY on the custom-validator record (the raw
// task_complete was withheld until validation) — it must lead, with
// the agent's own deliverable preserved after it.
completionSummary: "App verification passed.\n\ndeployed",
roomId: "origin-room-7",
replyToExternalMessageId: "discord-msg-7",
},
Expand Down Expand Up @@ -502,6 +505,80 @@ describe("SwarmCoordinatorService", () => {
await coordinator.stop();
});

it("relays the head of a long pure-prose deliverable instead of destroying it (#11605)", async () => {
const acp = makeAcpStub({
agentType: "codex",
workdir: "/tmp/wd",
metadata: { label: "plan-task", originRoomId: "origin-room-plan" },
});
const runtime = makeRuntime({ [AcpService.serviceType]: acp });
const coordinator = await SwarmCoordinatorService.start(runtime);

const fired = vi.fn(async () => {});
coordinator.setSwarmCompleteCallback(fired);

// A dashboard/API-spawned task asked for "a detailed migration plan in
// your final message": 2.4KB of pure prose, no tool-output envelopes, so
// stripping is a no-op. Pre-fix this synthesized as literally
// "[output elided — 2496 chars]" — total data loss on the relay path
// (buildTaskResultLine posts completionSummary verbatim, no LLM pass).
const prose = "Step: migrate the users table, then the posts. ".repeat(52);
acp.emit("sess-prose", "task_complete", { response: prose });
await new Promise((r) => setTimeout(r, 0));

expect(fired).toHaveBeenCalledTimes(1);
const summary = fired.mock.calls[0][0].tasks[0].completionSummary;
expect(summary).not.toBe(`[output elided — ${prose.length} chars]`);
expect(summary.startsWith("Step: migrate the users table")).toBe(true);
// Marker records the post-strip length (strip trims trailing whitespace).
expect(summary).toMatch(/… \[output truncated — \d+ chars total\]$/);
expect(summary.length).toBeLessThanOrEqual(2000);
await coordinator.stop();
});

it("posts the validated verdict plus the deliverable head when finalText exceeds the relay cap (#11605)", async () => {
const acp = makeAcpStub({
agentType: "codex",
workdir: "/tmp/wd",
metadata: {
label: "build-app",
originRoomId: "origin-room-verify",
validator: {
service: "app-verification",
method: "verifyApp",
params: { appName: "demo-app" },
},
},
});
const verification = {
verifyApp: vi.fn(async () => ({ verdict: "pass", checks: [] })),
};
const runtime = makeRuntime({
[AcpService.serviceType]: acp,
"app-verification": verification,
});
const coordinator = await SwarmCoordinatorService.start(runtime);

const fired = vi.fn(async () => {});
coordinator.setSwarmCompleteCallback(fired);

// Raw ACP finalText over the 2KB cap. Pre-fix the read ladder took
// `response` first and the sanitizer hard-replaced it, so the user saw
// "[output elided — 3000 chars]" — the "App verification passed." verdict
// that ONLY this record carries never posted.
const longFinal = "Built the demo app end to end. ".repeat(97); // ~3KB
acp.emit("sess-verified-long", "task_complete", { response: longFinal });
await new Promise((r) => setTimeout(r, 0));

expect(fired).toHaveBeenCalledTimes(1);
const summary = fired.mock.calls[0][0].tasks[0].completionSummary;
expect(summary.startsWith("App verification passed.")).toBe(true);
expect(summary).toContain("Built the demo app end to end.");
expect(summary).not.toContain("[output elided");
expect(summary.length).toBeLessThanOrEqual(2000);
await coordinator.stop();
});

it("falls back to the default summary when the response was ONLY tool output (#11578)", async () => {
const acp = makeAcpStub({
agentType: "codex",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,28 @@ describe("elideLongBlocks", () => {
expect(elideLongBlocks("short", 2000)).toBe("short");
});

it("elides a remnant over the cap into a length marker", () => {
it("TRUNCATES an over-cap remnant, preserving the head (#11605 destroyed it)", () => {
// Regression for 37813124bf (#11605): a legit long deliverable (pure
// prose, no envelopes) was hard-REPLACED by the elision marker — total
// data loss. It must instead relay the head plus a truncation marker.
const prose = "Step 1: back up the database. ".repeat(100); // 3000 chars
const out = elideLongBlocks(prose);
expect(out).not.toBe(`[output elided — ${prose.length} chars]`);
expect(out.startsWith("Step 1: back up the database.")).toBe(true);
expect(out).toContain(`${prose.length} chars total]`);
});

it("bounds the truncated result to the cap", () => {
const big = "x".repeat(DEFAULT_MAX_RELAY_CHARS + 500);
const out = elideLongBlocks(big);
expect(out).toBe(`[output elided — ${big.length} chars]`);
expect(out.length).toBeLessThan(60);
expect(out.length).toBeLessThanOrEqual(DEFAULT_MAX_RELAY_CHARS);
expect(out).toContain(`${big.length} chars total]`);
});

it("is idempotent: re-sanitizing truncated output is a no-op (buildTaskResultLine re-applies it)", () => {
const big = "w".repeat(5000);
const once = elideLongBlocks(big);
expect(elideLongBlocks(once)).toBe(once);
});

it("keeps text exactly at the cap", () => {
Expand All @@ -104,11 +121,27 @@ describe("elideLongBlocks", () => {
});

describe("sanitizeCompletionRelay", () => {
it("strips envelopes then elides an oversized remnant", () => {
it("strips envelopes then truncates the oversized remnant, keeping the head", () => {
const remnant = "z".repeat(DEFAULT_MAX_RELAY_CHARS + 100);
const input = `${remnant}\n[tool output: t]\nbody\n[/tool output]`;
const out = sanitizeCompletionRelay(input);
expect(out).toBe(`[output elided — ${remnant.length} chars]`);
expect(out.startsWith("zzz")).toBe(true);
expect(out).toContain(`${remnant.length} chars total]`);
expect(out).not.toContain("[tool output:");
expect(out.length).toBeLessThanOrEqual(DEFAULT_MAX_RELAY_CHARS);
});

it("does NOT reduce a long pure-prose deliverable to a bare marker (#11605 regression)", () => {
// The confirmed failure: a 2.4KB detailed-migration-plan answer (strip is
// a no-op — no envelopes) synthesized as literally the elision marker.
const prose = "Here is the detailed migration plan you asked for. ".repeat(
48,
); // ~2.4KB
const out = sanitizeCompletionRelay(prose);
expect(out).not.toBe(`[output elided — ${prose.length} chars]`);
expect(
out.startsWith("Here is the detailed migration plan you asked for."),
).toBe(true);
});

it("returns empty when the whole payload was tool output", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ import type { IAgentRuntime } from "@elizaos/core";
import { logger, Service } from "@elizaos/core";
import { AcpService } from "./acp-service.js";
import { OrchestratorTaskService } from "./orchestrator-task-service.js";
import { sanitizeCompletionRelay } from "./transcript-sanitizer.js";
import {
DEFAULT_MAX_RELAY_CHARS,
sanitizeCompletionRelay,
} from "./transcript-sanitizer.js";
import { TERMINAL_SESSION_STATUSES } from "./types.js";

export const SWARM_COORDINATOR_SERVICE_TYPE = "SWARM_COORDINATOR";
Expand Down Expand Up @@ -976,9 +979,27 @@ export class SwarmCoordinatorService extends Service {
readString(record, "summary") ??
readString(record, "message") ??
readString(record, "text");
const sanitizedSummary = rawSummary
? sanitizeCompletionRelay(rawSummary)
// A custom-validator completion carries its user-facing verdict in
// `summary` ("App verification passed.") while `response` still holds the
// raw ACP finalText spread from enrichedData. The verdict exists ONLY on
// this record (the raw task_complete was withheld until validation), so it
// must not be shadowed by `response` in the read ladder: lead with it,
// then append the sanitized deliverable, budgeted so the combined text
// still fits the relay cap (buildTaskResultLine re-sanitizes defensively).
const validatorVerdict = isCustomValidatorResult(record)
? (readString(record, "summary")?.trim() ?? "")
: "";
const bodyBudget = validatorVerdict
? DEFAULT_MAX_RELAY_CHARS - validatorVerdict.length - 2
: DEFAULT_MAX_RELAY_CHARS;
const sanitizedBody = rawSummary
? sanitizeCompletionRelay(rawSummary, bodyBudget)
: "";
const sanitizedSummary = validatorVerdict
? sanitizedBody && sanitizedBody !== validatorVerdict
? `${validatorVerdict}\n\n${sanitizedBody}`
: validatorVerdict
: sanitizedBody;
const completionSummary =
sanitizedSummary ||
(terminalStatus === "completed"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,29 @@ export function stripToolTranscript(text: string): string {
}

/**
* Hard-cap any oversized text remnant. If `text` exceeds `maxChars`, replace it
* entirely with a short elision marker recording the original length. Applied
* Hard-cap any oversized text remnant. If `text` exceeds `maxChars`, TRUNCATE
* it: keep the head and append a marker recording the original length. Applied
* AFTER envelope stripping as defense-in-depth: even a remnant that is not a
* recognized envelope (raw JSON, an unfenced dump) is bounded before relay.
*
* Never replaces the text wholesale — a legit long deliverable (a pure-prose
* plan or report over the cap) must relay its head, not vanish into a marker
* (regression from 37813124bf / #11605: the synthesis path posted literally
* "[output elided — N chars]" as the completion summary).
*
* The truncated result is bounded to `maxChars`, so re-applying this function
* (buildTaskResultLine re-sanitizes defensively) is a no-op.
*/
export function elideLongBlocks(
text: string,
maxChars: number = DEFAULT_MAX_RELAY_CHARS,
): string {
if (!text) return "";
if (text.length <= maxChars) return text;
return `[output elided — ${text.length} chars]`;
const marker = `… [output truncated — ${text.length} chars total]`;
const headBudget = maxChars - marker.length - 1; // 1 = joining newline
if (headBudget <= 0) return marker; // degenerate tiny cap: marker only
return `${text.slice(0, headBudget).trimEnd()}\n${marker}`;
}

/**
Expand Down
Loading