Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
57f673b
feat(types): add ResultPreviewMode type for result rendering
cad0p May 28, 2026
8ba3053
feat(settings): add markdown result-preview settings (mode, expanded,…
cad0p May 28, 2026
6d68ee4
feat(index): fall back through record.error in XML payload + remove s…
cad0p May 28, 2026
8ae49a9
refactor(index): split renderer into renderHeader + renderBody
cad0p May 28, 2026
42778fa
feat(index): render result body as Markdown when resultPreviewMode is…
cad0p May 28, 2026
3dabd3e
feat(menu): expose result-preview settings in /agents → Settings
cad0p May 28, 2026
fca7361
test(settings): add result preview settings sanitizer + persistence t…
cad0p May 28, 2026
8a78c58
test(index): add XML payload formatTaskNotification tests
cad0p May 28, 2026
1c05703
test(index): add UI details buildNotificationDetails tests
cad0p May 28, 2026
c7f5946
test(renderer): add regression-lock snapshot tests for upstream baseline
cad0p May 28, 2026
66d1f0f
test(renderer): add markdown rendering and expanded setting tests
cad0p May 28, 2026
caa0ca3
test(menu): add settings menu structure tests for result preview entries
cad0p May 28, 2026
d8c64ea
fix(lint): resolve linting issues in test files
cad0p May 28, 2026
630c7b4
refactor(index): extract magic numbers to named constants with ration…
cad0p May 28, 2026
802aa58
test: rewrite test files to exercise production code instead of in-te…
cad0p May 28, 2026
c520513
fix: remove unused imports in test files
cad0p May 28, 2026
53985e3
refactor(index): mark renderer testability exports as @internal (CLEA…
cad0p May 28, 2026
ef1a7c0
fix(index): safe-truncate failure preview to avoid splitting surrogat…
cad0p May 28, 2026
0cf3537
refactor: tighten internal API surface (stripInternal, @internal tags…
cad0p May 28, 2026
6943b58
test(renderer): add vitest snapshot for plain-mode byte-equivalent re…
cad0p May 28, 2026
dd778bf
test: add markdown-collapse boundary cases + verify production default
cad0p May 28, 2026
edf5caa
test: strengthen safeTruncate surrogate-pair boundary assertions
cad0p May 28, 2026
460e31d
refactor(index): extract buildResultPreview shared helper
cad0p May 28, 2026
ac9131a
test: strengthen safeTruncate surrogate-pair boundary assertions
cad0p May 28, 2026
e67f0f2
test: remove tautology test masquerading as production-default verifi…
cad0p May 28, 2026
66feb7e
test: code-fence-cut test now actually truncates mid-fence
cad0p May 28, 2026
c82b98b
test(renderer): snapshot d.others group rendering for status combinat…
cad0p May 28, 2026
5a7d606
test: clarify boundary value rationale in surrogate-pair test
cad0p May 28, 2026
7fa55a6
refactor(index): drop speculative ?? DEFAULT_FAILURE_PREVIEW_MAX_CHAR…
cad0p May 28, 2026
76fd5f5
docs(index): trim safeTruncate JSDoc + redundant inline comment (BLOA…
cad0p May 28, 2026
2c93e99
docs(index): trim buildResultPreview JSDoc to one-sentence contract (…
cad0p May 28, 2026
a3532f0
docs(index): merge stacked JSDoc blocks for @internal testability exp…
cad0p May 28, 2026
d1e87d4
docs(index): drop redundant group-header comment on plain-mode consta…
cad0p May 28, 2026
5199895
docs(index): delete reviewer-history comment "Plain mode - original b…
cad0p May 28, 2026
ec45a86
docs(index): delete inline comment duplicating resultPreviewExpanded …
cad0p May 28, 2026
3af3de1
refactor(index): assert failurePreviewMaxChars contract instead of si…
cad0p May 28, 2026
abb2524
docs(index): restore lost JSDoc descriptions on render header/body (O…
cad0p May 28, 2026
8ca9a76
docs(index): trim DEFAULT_FAILURE_PREVIEW_MAX_CHARS comment to value-…
cad0p May 28, 2026
f5710d8
test: delete tautological menu-structure.test.ts (B1)
cad0p May 28, 2026
c2fc746
test: delete duplicate UI-path tests from notification-format.test.ts…
cad0p May 28, 2026
fbea8ef
test: replace smoke-test file with distinguishing expanded-vs-collaps…
cad0p May 28, 2026
87ebba5
test: delete flaky timing assertion in notification-format.test.ts (B6)
cad0p May 28, 2026
f312db6
test: delete redundant structural-properties test in renderer-regress…
cad0p May 28, 2026
c33d556
test: strengthen empty-body assertion to actually test no-throw contr…
cad0p May 28, 2026
b67106a
test: cover buildResultPreview throw branch on missing failurePreview…
cad0p May 28, 2026
83864ba
test: complete B2 — delete residual UI-path duplicate from notificati…
cad0p May 28, 2026
9eb07f1
test(renderer): update regression-lock snapshots for upstream ⟳→↻ gly…
cad0p Jun 1, 2026
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
247 changes: 183 additions & 64 deletions src/index.ts

Large diffs are not rendered by default.

29 changes: 28 additions & 1 deletion src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import type { JoinMode } from "./types.js";
import type { JoinMode, ResultPreviewMode } from "./types.js";

export interface SubagentsSettings {
maxConcurrent?: number;
Expand Down Expand Up @@ -48,6 +48,12 @@ export interface SubagentsSettings {
* against. Defaults to false: subagents may use any model.
*/
scopeModels?: boolean;
/** Result preview rendering mode. Defaults to "markdown". */
resultPreviewMode?: ResultPreviewMode;
/** Always show expanded result preview, ignoring pi's expanded flag. Defaults to true. */
resultPreviewExpanded?: boolean;
/** Max chars for failure preview before truncation. Defaults to 65536 (64 KiB). */
failurePreviewMaxChars?: number;
}

/** Setter hooks used by applySettings to wire persisted values into in-memory state. */
Expand All @@ -58,19 +64,24 @@ export interface SettingsAppliers {
setDefaultJoinMode: (mode: JoinMode) => void;
setSchedulingEnabled: (b: boolean) => void;
setScopeModels: (enabled: boolean) => void;
setResultPreviewMode: (mode: ResultPreviewMode) => void;
setResultPreviewExpanded: (expanded: boolean) => void;
setFailurePreviewMaxChars: (chars: number) => void;
}

/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
export type SettingsEmit = (event: string, payload: unknown) => void;

const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
const VALID_RESULT_PREVIEW_MODES: ReadonlySet<string> = new Set<ResultPreviewMode>(["plain", "markdown"]);

// Sanity ceilings — prevent hand-edited configs from asking for values that
// make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
// that any realistic power-user setting passes through.
const MAX_CONCURRENT_CEILING = 1024;
const MAX_TURNS_CEILING = 10_000;
const GRACE_TURNS_CEILING = 1_000;
export const RESULT_PREVIEW_MAX_CHARS_CEILING = 1_048_576;

/** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
function sanitize(raw: unknown): SubagentsSettings {
Expand Down Expand Up @@ -107,6 +118,19 @@ function sanitize(raw: unknown): SubagentsSettings {
if (typeof r.scopeModels === "boolean") {
out.scopeModels = r.scopeModels;
}
if (typeof r.resultPreviewMode === "string" && VALID_RESULT_PREVIEW_MODES.has(r.resultPreviewMode)) {
out.resultPreviewMode = r.resultPreviewMode as ResultPreviewMode;
}
if (typeof r.resultPreviewExpanded === "boolean") {
out.resultPreviewExpanded = r.resultPreviewExpanded;
}
if (
Number.isInteger(r.failurePreviewMaxChars) &&
(r.failurePreviewMaxChars as number) >= 1 &&
(r.failurePreviewMaxChars as number) <= RESULT_PREVIEW_MAX_CHARS_CEILING
) {
out.failurePreviewMaxChars = r.failurePreviewMaxChars as number;
}
return out;
}

Expand Down Expand Up @@ -163,6 +187,9 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels);
if (s.resultPreviewMode) appliers.setResultPreviewMode(s.resultPreviewMode);
if (typeof s.resultPreviewExpanded === "boolean") appliers.setResultPreviewExpanded(s.resultPreviewExpanded);
if (typeof s.failurePreviewMaxChars === "number") appliers.setFailurePreviewMaxChars(s.failurePreviewMaxChars);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export interface AgentConfig {

export type JoinMode = 'async' | 'group' | 'smart';

export type ResultPreviewMode = "plain" | "markdown";

export interface AgentRecord {
id: string;
type: SubagentType;
Expand Down
157 changes: 157 additions & 0 deletions test/notification-details.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import { buildNotificationDetails } from "../src/index.js";
import type { AgentRecord, SubagentsSettings } from "../src/types.js";

describe("UI details buildNotificationDetails", () => {
const createRecord = (overrides: Partial<AgentRecord> = {}): AgentRecord => ({
id: "test-agent-1",
description: "Test Agent",
status: "completed",
startedAt: Date.now() - 5000,
completedAt: Date.now(),
result: undefined,
error: undefined,
lifetimeUsage: { inputTokens: 100, outputTokens: 50 },
session: { id: "session-1", contextTokens: 1000, maxContextTokens: 10000 },
compactionCount: 0,
...overrides,
});

const defaultSettings: SubagentsSettings = {
failurePreviewMaxChars: 65536,
};

it("success agent with 1KB result contains full result", () => {
const result1KB = "x".repeat(1024);
const record = createRecord({ result: result1KB });

const details = buildNotificationDetails(record, defaultSettings);

expect(details.resultPreview).toBe(result1KB);
});

it("success agent with 100KB result contains full result", () => {
const result100KB = "x".repeat(100 * 1024);
const record = createRecord({ result: result100KB });

const details = buildNotificationDetails(record, defaultSettings);

expect(details.resultPreview).toBe(result100KB);
});

it("failed agent with error message shows error in resultPreview", () => {
const record = createRecord({
status: "error",
result: undefined,
error: "boom",
});

const details = buildNotificationDetails(record, defaultSettings);

expect(details.resultPreview).toBe("boom");
});

it("stopped agent with no output shows fallback message", () => {
const record = createRecord({
status: "stopped",
result: undefined,
error: undefined,
});

const details = buildNotificationDetails(record, defaultSettings);

expect(details.resultPreview).toBe("No output.");
});

it("failed agent with large error gets truncated", () => {
const largeError = "x".repeat(100 * 1024);
const record = createRecord({
status: "error",
result: undefined,
error: largeError,
});

const settings: SubagentsSettings = { failurePreviewMaxChars: 1000 };
const details = buildNotificationDetails(record, settings);

const expectedTruncated = largeError.slice(0, 1000) + "\n…(truncated, see transcript)";
expect(details.resultPreview).toBe(expectedTruncated);
});

it("aborted status is not subject to failure cap", () => {
const largeResult = "x".repeat(100 * 1024);
const record = createRecord({
status: "aborted",
result: largeResult,
});

const settings: SubagentsSettings = { failurePreviewMaxChars: 1000 };
const details = buildNotificationDetails(record, settings);

expect(details.resultPreview).toBe(largeResult);
});

it("UI path: surrogate pairs handled gracefully", () => {
const emoji = "🚀".repeat(1000);
const record = createRecord({ status: "error", error: emoji, result: undefined });
// 1999 = (1000 emoji × 2 UTF-16 units) - 1; cuts the LAST emoji's high surrogate, exercising safeTruncate's drop-trailing-high-surrogate path
const settings = { failurePreviewMaxChars: 1999 };

const details = buildNotificationDetails(record, settings);

// Should not contain Unicode replacement characters
expect(details.resultPreview).not.toContain("�");
expect(details.resultPreview).toContain("truncated, see transcript");
});

it("UI path: high surrogate at exact boundary drops unpaired surrogate", () => {
const emoji = "🚀".repeat(1000); // 2000 UTF-16 units total
const record = createRecord({ status: "error", error: emoji, result: undefined });
const settings = { failurePreviewMaxChars: 1999 }; // Slice would land exactly at high surrogate of last emoji

const details = buildNotificationDetails(record, settings);

// Should drop the unpaired high surrogate + add truncation suffix, total length 1998 + 29 = 2027
expect(details.resultPreview.length).toBe(2027);
expect(details.resultPreview).not.toContain("�");
expect(details.resultPreview).toContain("truncated, see transcript");
});

it("throws when failurePreviewMaxChars is undefined on error status", () => {
const record = createRecord({ status: "error", error: "boom", result: undefined });
const settings = {} as SubagentsSettings; // no failurePreviewMaxChars

expect(() => buildNotificationDetails(record, settings)).toThrow(/failurePreviewMaxChars must be a number/);
});

it("UI path: isolated low surrogate does not crash", () => {
const malformedInput = "hello" + String.fromCharCode(0xDC00) + "world"; // Bare low surrogate
const record = createRecord({ status: "error", error: malformedInput, result: undefined });
const settings = { failurePreviewMaxChars: 8 }; // Truncate within the string

expect(() => {
const details = buildNotificationDetails(record, settings);
expect(details.resultPreview).toBeDefined();
}).not.toThrow();
});

it("UI path: no truncation when input equals maxChars", () => {
const input = "hello";
const record = createRecord({ status: "error", error: input, result: undefined });
const settings = { failurePreviewMaxChars: 5 };

const details = buildNotificationDetails(record, settings);

expect(details.resultPreview).toBe("hello");
expect(details.resultPreview).not.toContain("truncated");
});

it("UI path: cap zero returns truncation message", () => {
const record = createRecord({ status: "error", error: "hello", result: undefined });
const settings = { failurePreviewMaxChars: 0 };

const details = buildNotificationDetails(record, settings);

expect(details.resultPreview).toBe("\n…(truncated, see transcript)");
});
});
113 changes: 113 additions & 0 deletions test/notification-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { buildNotificationDetails, formatTaskNotification } from "../src/index.js";
import type { AgentRecord } from "../src/types.js";

describe("notification format edge cases", () => {
const createRecord = (overrides: Partial<AgentRecord> = {}): AgentRecord => ({
id: "test-1",
description: "Test Agent",
status: "completed",
toolUses: 2,
startedAt: Date.now() - 5000,
completedAt: Date.now(),
lifetimeUsage: { totalTokens: 150, inputTokens: 100, outputTokens: 50 },
result: "Test result",
...overrides,
});

it("failure truncation handles surrogate pairs gracefully", () => {
const emoji = "🚀".repeat(1000); // Each emoji is 2 UTF-16 code units
const record = createRecord({ status: "error", error: emoji, result: undefined });
// 1999 = (1000 emoji × 2 UTF-16 units) - 1; cuts the LAST emoji's high surrogate, exercising safeTruncate's drop-trailing-high-surrogate path
const settings = { failurePreviewMaxChars: 1999 };

const xml = formatTaskNotification(record, settings);

// Should not contain Unicode replacement characters
expect(xml).not.toContain("�");
expect(xml).toContain("truncated, see transcript");
});

it("high surrogate at exact boundary drops unpaired surrogate", () => {
const emoji = "🚀".repeat(1000); // 2000 UTF-16 units total
const record = createRecord({ status: "error", error: emoji, result: undefined });
const settings = { failurePreviewMaxChars: 1999 }; // Slice would land exactly at high surrogate of last emoji

const xml = formatTaskNotification(record, settings);
const resultMatch = xml.match(/<result>(.*?)<\/result>/s);
const resultContent = resultMatch?.[1] || "";

// Should drop the unpaired high surrogate + add truncation suffix, total length 1998 + 29 = 2027
expect(resultContent.length).toBe(2027);
expect(resultContent).not.toContain("�");
expect(resultContent).toContain("truncated, see transcript");
});

it("isolated low surrogate does not crash", () => {
const malformedInput = "hello" + String.fromCharCode(0xDC00) + "world"; // Bare low surrogate
const record = createRecord({ status: "error", error: malformedInput, result: undefined });
const settings = { failurePreviewMaxChars: 8 }; // Truncate within the string

expect(() => {
const xml = formatTaskNotification(record, settings);
expect(xml).toBeDefined();
}).not.toThrow();
});

it("no truncation when input equals maxChars", () => {
const input = "hello";
const record = createRecord({ status: "error", error: input, result: undefined });
const settings = { failurePreviewMaxChars: 5 };

const xml = formatTaskNotification(record, settings);
const resultMatch = xml.match(/<result>(.*?)<\/result>/s);
const resultContent = resultMatch?.[1] || "";

expect(resultContent).toBe("hello");
expect(resultContent).not.toContain("truncated");
});

it("cap zero returns empty string", () => {
const record = createRecord({ status: "error", error: "hello", result: undefined });
const settings = { failurePreviewMaxChars: 0 };

const xml = formatTaskNotification(record, settings);
const resultMatch = xml.match(/<result>(.*?)<\/result>/s);
const resultContent = resultMatch?.[1] || "";

expect(resultContent).toBe("\n…(truncated, see transcript)");
});





it("empty body renders sensibly", () => {
const record = createRecord({ result: "", error: undefined });
const settings = {};

const xml = formatTaskNotification(record, settings);
const details = buildNotificationDetails(record, settings);

expect(xml).toContain("<result>No output.</result>");
expect(details.resultPreview).toBe("No output.");
});



it("mixed status types in error/stopped/aborted", () => {
const errorRecord = createRecord({ status: "error", error: "Error message", result: undefined });
const stoppedRecord = createRecord({ status: "stopped", error: "Stopped", result: undefined });
const abortedRecord = createRecord({ status: "aborted", result: "Partial result" });

const settings = { failurePreviewMaxChars: 100 };

const errorXml = formatTaskNotification(errorRecord, settings);
const stoppedXml = formatTaskNotification(stoppedRecord, settings);
const abortedXml = formatTaskNotification(abortedRecord, settings);

expect(errorXml).toContain("<result>Error message</result>");
expect(stoppedXml).toContain("<result>Stopped</result>");
expect(abortedXml).toContain("<result>Partial result</result>");
});
});
Loading