From 18ebecfe7270a4eacc367afd6262bd22e7808308 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 29 Sep 2026 16:46:28 -0700 Subject: [PATCH] fix(subagent): name mailbox digest blobs for parent fetch The parent prompt only reads tool-output URIs that a truncation notice named. Mailbox digest report_uri was a bare pointer, and a failed blob write dropped Findings with no inline fallback. --- docs/ARCHITECTURE.md | 4 +- docs/IMPLEMENTATION.md | 2 +- docs/TUI.md | 5 +- src/subagent/fleet-dry-drive.ts | 24 +++++ src/subagent/mailbox-mail-drive.test.ts | 121 +++++++++++++++++++----- src/subagent/mailbox-mail-drive.ts | 21 ++-- 6 files changed, 144 insertions(+), 33 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ab0a44b67..86df5b6b4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -148,12 +148,12 @@ A worker deny-on-ask registers a harness-owned denied-call envelope (`src/permis Two directors, selected by role: -- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Yielding while a live fleet is running is allowed (idle-with-fleet); the open-task nudge does not rewrite that wait/reply, and the workflow idle rail does not declare the parent stuck while helpers run. The TUI idle-with-fleet seed alone is not occupancy: an empty fleet still auto-continues slash workflows. Occupancy, mailbox, and operator inbounds reset the workflow idle counter. When a worker finishes or fails while the parent is idle (including idle-with-fleet), occupancy delivers mailbox mail as system inbound so Skywalker starts a new turn without polling `wait_agents`. Mailbox mail is a Summary + Blockers digest with a blob pointer to the full report, not the full report JSON. When the fleet goes dry with tasks still todo/doing, the TUI runtime re-enters the parent with collected worker reports rather than settling idle. Already-collected IDs in that continuation are id/status/description only. +- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Yielding while a live fleet is running is allowed (idle-with-fleet); the open-task nudge does not rewrite that wait/reply, and the workflow idle rail does not declare the parent stuck while helpers run. The TUI idle-with-fleet seed alone is not occupancy: an empty fleet still auto-continues slash workflows. Occupancy, mailbox, and operator inbounds reset the workflow idle counter. When a worker finishes or fails while the parent is idle (including idle-with-fleet), occupancy delivers mailbox mail as system inbound so Skywalker starts a new turn without polling `wait_agents`. Mailbox mail is a Summary + Blockers digest (Findings when Summary is empty) with a blob pointer named for `read_file`, not the full report JSON. A failed blob write inlines a truncated report with an honest not-retrievable notice. When the fleet goes dry with tasks still todo/doing, the TUI runtime re-enters the parent with collected worker reports rather than settling idle. Already-collected IDs in that continuation are id/status/description only. Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, force/uncontained worktree changes, sensitive-path and opaque-wrapper shell still ask; contained non-force `git worktree add`/`remove`/`prune` and `list` auto-allow; shell file-mutation denied). It is not a separate edit/plan mode. - **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less turn **after tools** completes only with the four-heading envelope (Summary, Findings, Blockers, Paths). Assistant text that prints explicit `` markup is treated as attempted tool use, not narration: one **verbatim-tool-call** nudge asks the worker to re-issue a real `tool_call` and does not count toward the tool-less spiral. A missing envelope otherwise nudges once (**incomplete-report**) and a second tool-less turn still without the envelope salvages as **incomplete-report-stop** (once; later tool-less turns wait). Explore/read-only workers that used tools then replied with findings remain normal completes; `requireEvidence` (off by default, set per director) additionally requires at least one read before a tool-less spawn-only reply can complete. `requirePlanSubstance` (counsel or `intent=plan`, not `modelRole === "plan"`) additionally requires Findings to contain files/paths, acceptance criteria, non-goals, risks, and ordered steps with a non-placeholder line each — four headings with stub Findings are incomplete-report, not an attachable plan. After real tool work, wrap-up Findings that are not placeholder or outline-only complete instead of being salvaged as a stub plan. Reads done through `run_shell` count as evidence too — `src/subagent/shell-evidence.ts` classifies shell reads (`cat`, `grep`, `sed` without `-i`, …) over the same subject expansion the auto-shell policy uses — but there is no corresponding shell-write evidence or file-write requirement: a run that never touches a file still completes normally once it replies with the envelope. There is no turn budget. Operator/parent cancel after any progress returns a **cancelled** salvage report (partial findings + tool activity) instead of a bare cancel string; cancel before progress still surfaces as cancelled-by-operator. There is no repetition/no-progress/never-acted/never-edited hard stop and no fingerprint-based re-dispatch block — a genuinely stuck worker runs until it completes, stalls, hits an opt-in wall-clock deadline, or is cancelled. - `spawn_agent` starts each worker and records it in the caller's fleet mailbox. On the TUI primary, mailbox mail is the collect path: occupancy takes uncollected terminals and re-enters the parent as system inbound with a Summary + Blockers digest and a blob pointer to the full report. Nested orchestrators collect through mailbox mail the same way. A mounted `wait_agents` (exec primary) collects worker reports directly instead. Already-collected waits return status without a second report or error body. Wait JSON includes `stop_reason` from the session when present so a salvage that is wait-`done` is not mistaken for a clean complete, and so parent-initiated interrupt (`interrupted`) is not mistaken for operator-cancel (`cancelled`). Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted. Failed and incomplete-report salvage tell the parent to diagnose from the report or error and MAY spawn one successor with a changed brief. A parent-initiated interrupt is a resumable pause: wait unblocks with `stop_reason: interrupted` (often while the session is still running and has no report); the parent should `resume_agent` or re-wait, and must not spawn a successor against a still-live worker. Successor only if that session is no longer resumable. Operator-cancelled salvage asks the parent to synthesize Findings and Paths and wait for the operator instead of auto-starting another specialist. Identical re-dispatch of the same brief stays refused at the prompt / spawn-handoff layer, except a recoverable/continuable child failure (`continuable: true`) MAY spawn one successor with the same brief; there is no fingerprint-based re-dispatch hard-block. Deadline hints are advisory only — an identical re-dispatch is still admitted at runtime. Parent hints are prepended on salvage reports returned to the parent. The runtime does not auto-spawn successors. + `spawn_agent` starts each worker and records it in the caller's fleet mailbox. On the TUI primary, mailbox mail is the collect path: occupancy takes uncollected terminals and re-enters the parent as system inbound with a Summary + Blockers digest (Findings when Summary is empty) and a blob pointer named for `read_file`. Nested orchestrators collect through mailbox mail the same way. A mounted `wait_agents` (exec primary) collects worker reports directly instead. Already-collected waits return status without a second report or error body. Wait JSON includes `stop_reason` from the session when present so a salvage that is wait-`done` is not mistaken for a clean complete, and so parent-initiated interrupt (`interrupted`) is not mistaken for operator-cancel (`cancelled`). Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted. Failed and incomplete-report salvage tell the parent to diagnose from the report or error and MAY spawn one successor with a changed brief. A parent-initiated interrupt is a resumable pause: wait unblocks with `stop_reason: interrupted` (often while the session is still running and has no report); the parent should `resume_agent` or re-wait, and must not spawn a successor against a still-live worker. Successor only if that session is no longer resumable. Operator-cancelled salvage asks the parent to synthesize Findings and Paths and wait for the operator instead of auto-starting another specialist. Identical re-dispatch of the same brief stays refused at the prompt / spawn-handoff layer, except a recoverable/continuable child failure (`continuable: true`) MAY spawn one successor with the same brief; there is no fingerprint-based re-dispatch hard-block. Deadline hints are advisory only — an identical re-dispatch is still admitted at runtime. Parent hints are prepended on salvage reports returned to the parent. The runtime does not auto-spawn successors. #### Model-family policy (`src/agent/model-family-policy.ts`) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 429ee35cf..32b9b90a7 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -212,7 +212,7 @@ Listings are list-free, dumps are dump-locked: a bounded `ls`/`tree` prints name - **Alt+Enter** queues a follow-up (kind `"queue"`) delivered only on **session-idle** — parent-idle **and** no live fleet lanes (`run` goes idle). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run. -Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count (same liveness rule as the progress strip: interrupted leftovers are not live); the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. The workflow idle rail honors live helper occupancy, not the TUI idle-with-fleet seed: a live fleet is not a stuck parent, an empty fleet still auto-continues slash workflows, and occupancy / mailbox / operator inbounds reset the workflow idle counter. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. The mail payload is a Summary + Blockers digest with a blob pointer, not the full report JSON; fleet-dry continuation lists already-collected IDs as id/status/description only and does not re-deliver an ID mailbox mail is already sending. Skip that shot when a fleet-dry open-task continuation is latched. Both wakes wait for send to settle as accepted before taking reports; a pending Promise is not a continuation, and a failed or uncertain send idles the parent. Exec-primary `wait_agents` mounts with no yield predicate (`createWaitAgentsTool({ sessions, fleetRecords })`) and blocks to ready/timeout/abort; parent interrupt/abort cancels that parked waiter via the tool AbortSignal (`timed_out`, workers stay running) rather than a second waiter table. `interrupt_agent` on a child unblocks wait as interrupted through the mailbox overlay. `shouldYieldWait` remains a supported-but-unwired tool option (no production mount passes it). +Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count (same liveness rule as the progress strip: interrupted leftovers are not live); the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. The workflow idle rail honors live helper occupancy, not the TUI idle-with-fleet seed: a live fleet is not a stuck parent, an empty fleet still auto-continues slash workflows, and occupancy / mailbox / operator inbounds reset the workflow idle counter. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. The mail payload is a Summary + Blockers digest (Findings when Summary is empty) with a blob pointer named for `read_file`, not the full report JSON; fleet-dry continuation lists already-collected IDs as id/status/description only and does not re-deliver an ID mailbox mail is already sending. Skip that shot when a fleet-dry open-task continuation is latched. Both wakes wait for send to settle as accepted before taking reports; a pending Promise is not a continuation, and a failed or uncertain send idles the parent. Exec-primary `wait_agents` mounts with no yield predicate (`createWaitAgentsTool({ sessions, fleetRecords })`) and blocks to ready/timeout/abort; parent interrupt/abort cancels that parked waiter via the tool AbortSignal (`timed_out`, workers stay running) rather than a second waiter table. `interrupt_agent` on a child unblocks wait as interrupted through the mailbox overlay. `shouldYieldWait` remains a supported-but-unwired tool option (no production mount passes it). `src/tui/stream-event-map.ts` maps reactor events onto the bridge's inbound events, and `src/tui/turn-state.ts` tracks the turn's status. `src/tui/turns-to-blocks.ts` hydrates a resumed session's stored turns into the same content blocks. diff --git a/docs/TUI.md b/docs/TUI.md index 73c4f8060..c9817da76 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -723,8 +723,9 @@ to idle — unless todo/doing tasks remain, in which case a system continuation starts before the fleet-0 event so the run stays busy and follow-ups wait one more turn. Occupancy wakes consume mailbox reports only after send settles as accepted; a failed or uncertain send idles the parent -so a later flush can retry. Mailbox mail is a Summary + Blockers digest with -a blob pointer to the full report. Fleet-dry continuation lists +so a later flush can retry. Mailbox mail is a Summary + Blockers digest +(Findings when Summary is empty) with a blob pointer named for `read_file`. +A failed blob write inlines a truncated report. Fleet-dry continuation lists already-collected IDs as id/status/description only. The mail and that fleet-dry continuation are runtime-to-agent traffic — the fleet board owns worker status — so neither paints a transcript row, and neither rehydrates as one. diff --git a/src/subagent/fleet-dry-drive.ts b/src/subagent/fleet-dry-drive.ts index 9ea3c8983..502774841 100644 --- a/src/subagent/fleet-dry-drive.ts +++ b/src/subagent/fleet-dry-drive.ts @@ -123,7 +123,9 @@ export interface MailboxWorkerDigest { status: string; description?: string; summary?: string; + findings?: string; blockers?: string; + report?: string; report_uri?: string; error?: string; error_uri?: string; @@ -209,6 +211,16 @@ function clipDigestSection(text: string): string { return `${text.slice(0, MAILBOX_DIGEST_SECTION_CHARS - 1).trimEnd()}…`; } +function mailboxReportSpillNotice(text: string, uri: string): string { + return truncationNotice({ + maxChars: MAILBOX_DIGEST_SECTION_CHARS, + remaining: Math.max(0, text.length - MAILBOX_DIGEST_SECTION_CHARS), + fullLength: text.length, + contentType: "text/plain", + uri, + }).trim(); +} + export async function spillWorkerField( text: string | undefined, agentId: string, @@ -261,12 +273,22 @@ export async function digestCollectedReport( parsed !== undefined && parsed.summary.length > 0 ? clipDigestSection(parsed.summary) : undefined; + const findings = + parsed !== undefined && summary === undefined && parsed.findings.length > 0 + ? clipDigestSection(parsed.findings) + : undefined; const blockers = parsed !== undefined ? clipDigestSection( parsed.blockers.length > 0 ? parsed.blockers : "None.", ) : undefined; + const reportInline = + report.report === undefined + ? undefined + : reportUri !== undefined + ? mailboxReportSpillNotice(report.report, reportUri) + : await clipField(report.report, report.agent_id, "report"); return { agent_id: report.agent_id, status: report.status, @@ -274,7 +296,9 @@ export async function digestCollectedReport( ? { description: report.description } : {}), ...(summary !== undefined ? { summary } : {}), + ...(findings !== undefined ? { findings } : {}), ...(blockers !== undefined ? { blockers } : {}), + ...(reportInline !== undefined ? { report: reportInline } : {}), ...(reportUri !== undefined ? { report_uri: reportUri } : {}), ...(report.error !== undefined ? { error: clipDigestSection(report.error) } diff --git a/src/subagent/mailbox-mail-drive.test.ts b/src/subagent/mailbox-mail-drive.test.ts index e4517af7a..4d94862f1 100644 --- a/src/subagent/mailbox-mail-drive.test.ts +++ b/src/subagent/mailbox-mail-drive.test.ts @@ -5,6 +5,7 @@ import { driveMailboxMail, latchMailboxMailDrive, MAILBOX_MAIL_WAKE_PREFIX, + mailboxMailReportUriHint, mailboxMailWakeLine, occupancyShouldYieldWait, } from "./mailbox-mail-drive.js"; @@ -12,6 +13,7 @@ import { createSubAgentSessionStore } from "./session-store.js"; import { digestCollectedReports, fleetDrySpillKey, + FLEET_DRY_REPORT_CHARS, type FleetDryMailboxRecord, } from "./fleet-dry-drive.js"; import { @@ -43,10 +45,12 @@ function mailboxReportsFromPrompt(prompt: string): Record[] { const line = mailboxMailWakeLine(); const start = prompt.indexOf(line); expect(start).toBeGreaterThanOrEqual(0); - const jsonStart = start + line.length + 1; - const jsonEnd = prompt.indexOf("\n", jsonStart); + const rest = prompt.slice(start + line.length); + const jsonStart = rest.indexOf("["); + expect(jsonStart).toBeGreaterThanOrEqual(0); + const jsonEnd = rest.indexOf("\n", jsonStart); return JSON.parse( - prompt.slice(jsonStart, jsonEnd === -1 ? undefined : jsonEnd), + rest.slice(jsonStart, jsonEnd === -1 ? undefined : jsonEnd), ) as Record[]; } @@ -102,6 +106,23 @@ describe("buildMailboxMailPrompt", () => { expect(prompt).toContain("shipped"); expect(prompt).toContain(mailboxMailWakeLine()); expect(prompt).not.toContain("already collected"); + expect(prompt).not.toContain(mailboxMailReportUriHint()); + }); + + test("names report_uri with a read_file truncation notice", () => { + const uri = `tool-output:///${fleetDrySpillKey("worker-1", "report")}`; + const prompt = buildMailboxMailPrompt([ + { + agent_id: "worker-1", + status: "done", + report_uri: uri, + }, + ]); + expect(prompt).toContain(mailboxMailReportUriHint()); + expect(prompt).toContain( + "use read_file with that URI (offset/limit supported)", + ); + expect(prompt).toContain(uri); }); test("does not paste a duplicate agent_id", () => { @@ -132,14 +153,16 @@ describe("digestCollectedReports", () => { ], store.writeBlob, ); - expect(digest).toEqual({ - agent_id: "worker-1", - status: "done", - description: "lane", - summary: "Shipped the digest.", - blockers: "Need a follow-up.", - report_uri: `tool-output:///${fleetDrySpillKey("worker-1", "report")}`, - }); + const reportUri = `tool-output:///${fleetDrySpillKey("worker-1", "report")}`; + expect(digest?.summary).toBe("Shipped the digest."); + expect(digest?.blockers).toBe("Need a follow-up."); + expect(digest?.report_uri).toBe(reportUri); + expect(digest?.report).toContain("[output truncated"); + expect(digest?.report).toContain(reportUri); + expect(digest?.report).toContain( + "use read_file with that URI (offset/limit supported)", + ); + expect(digest?.report).not.toContain("SECRET_FINDINGS_BODY"); expect( new TextDecoder().decode( store.blobs.get(fleetDrySpillKey("worker-1", "report"))?.bytes ?? @@ -147,6 +170,58 @@ describe("digestCollectedReports", () => { ), ).toBe(ENVELOPE_REPORT); }); + + test("empty Summary still surfaces Findings", async () => { + const store = fakeBlobStore(); + const report = [ + "## Summary", + "", + "## Findings", + "SECRET_EMPTY_SUMMARY_FINDINGS", + "", + "## Blockers", + "None.", + "", + "## Paths", + "src/subagent/fleet-dry-drive.ts", + ].join("\n"); + const [digest] = await digestCollectedReports( + [{ agent_id: "empty-sum", status: "done", report }], + store.writeBlob, + ); + expect(digest?.summary).toBeUndefined(); + expect(digest?.findings).toBe("SECRET_EMPTY_SUMMARY_FINDINGS"); + expect(digest?.blockers).toBe("None."); + expect(digest?.report_uri).toBe( + `tool-output:///${fleetDrySpillKey("empty-sum", "report")}`, + ); + }); + + test("writeBlob failure inlines a truncated report with NOT retrievable", async () => { + const original = `head-${"x".repeat(FLEET_DRY_REPORT_CHARS)}SECRET_FINDINGS_TAIL`; + const [digest] = await digestCollectedReports( + [{ agent_id: "boom", status: "done", report: original }], + async () => { + throw new Error("disk full"); + }, + ); + expect(digest?.report_uri).toBeUndefined(); + expect(digest?.report?.length).toBeLessThanOrEqual(FLEET_DRY_REPORT_CHARS); + expect(digest?.report).toContain("[output truncated"); + expect(digest?.report).toContain("NOT retrievable"); + expect(digest?.report).not.toContain("tool-output:///"); + expect(digest?.report).not.toContain("SECRET_FINDINGS_TAIL"); + }); + + test("missing writeBlob inlines the report instead of dropping Findings", async () => { + const [digest] = await digestCollectedReports([ + { agent_id: "no-blob", status: "done", report: ENVELOPE_REPORT }, + ]); + expect(digest?.report_uri).toBeUndefined(); + expect(digest?.report).toBe(ENVELOPE_REPORT); + expect(digest?.report).toContain("SECRET_FINDINGS_BODY"); + expect(digest?.summary).toBe("Shipped the digest."); + }); }); describe("driveMailboxMail", () => { @@ -205,18 +280,20 @@ describe("driveMailboxMail", () => { }); expect(driven).toBe(true); const parsed = mailboxReportsFromPrompt(sent[0] ?? ""); - expect(parsed).toEqual([ - { - agent_id: "done", - status: "done", - description: "lane", - summary: "Shipped the digest.", - blockers: "Need a follow-up.", - report_uri: `tool-output:///${fleetDrySpillKey("done", "report")}`, - }, - ]); + expect(parsed[0]?.agent_id).toBe("done"); + expect(parsed[0]?.status).toBe("done"); + expect(parsed[0]?.description).toBe("lane"); + expect(parsed[0]?.summary).toBe("Shipped the digest."); + expect(parsed[0]?.blockers).toBe("Need a follow-up."); + expect(parsed[0]?.report_uri).toBe( + `tool-output:///${fleetDrySpillKey("done", "report")}`, + ); + expect(String(parsed[0]?.report)).toContain("[output truncated"); + expect(String(parsed[0]?.report)).toContain( + "use read_file with that URI (offset/limit supported)", + ); + expect(sent[0]).toContain(mailboxMailReportUriHint()); expect(sent[0]).not.toContain("SECRET_FINDINGS_BODY"); - expect(sent[0]).not.toContain('"report":'); expect( new TextDecoder().decode( store.blobs.get(fleetDrySpillKey("done", "report"))?.bytes ?? diff --git a/src/subagent/mailbox-mail-drive.ts b/src/subagent/mailbox-mail-drive.ts index a73205b1b..070fbc3b2 100644 --- a/src/subagent/mailbox-mail-drive.ts +++ b/src/subagent/mailbox-mail-drive.ts @@ -30,6 +30,11 @@ export function mailboxMailWakeLine(): string { return `${MAILBOX_MAIL_WAKE_PREFIX} — occupancy delivered these worker reports (do not call wait_agents for these agent_ids):`; } +/** Names report_uri with truncation-notice language so the parent may fetch it. */ +export function mailboxMailReportUriHint(): string { + return "report_uri names a spilled full report — use read_file with that URI (offset/limit supported) to see the rest."; +} + /** * Whether inbound text is occupancy's mailbox mail. Internal runtime→agent * traffic — the fleet board already owns worker status and the payload is @@ -79,12 +84,16 @@ export function occupancyShouldYieldWait( return false; } -export function buildMailboxMailPrompt( - reports: readonly T[], -): string { - return [mailboxMailWakeLine(), JSON.stringify(dedupeByAgentId(reports))].join( - "\n", - ); +export function buildMailboxMailPrompt< + T extends { agent_id: string; report_uri?: string }, +>(reports: readonly T[]): string { + const unique = dedupeByAgentId(reports); + const lines = [mailboxMailWakeLine()]; + if (unique.some((report) => report.report_uri !== undefined)) { + lines.push(mailboxMailReportUriHint()); + } + lines.push(JSON.stringify(unique)); + return lines.join("\n"); } function hasDeliverableMailboxMail(