diff --git a/src/index.ts b/src/index.ts index 02aa8be5..6ca40323 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,8 +12,8 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent"; -import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui"; +import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getMarkdownTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent"; +import { Container, Key, Markdown, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui"; import { Type } from "@sinclair/typebox"; import { AgentManager } from "./agent-manager.js"; import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js"; @@ -27,8 +27,8 @@ import { type ModelRegistry, resolveModel } from "./model-resolver.js"; import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js"; import { SubagentScheduler } from "./schedule.js"; import { resolveStorePath, ScheduleStore } from "./schedule-store.js"; -import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js"; -import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js"; +import { applyAndEmitLoaded, RESULT_PREVIEW_MAX_CHARS_CEILING, type SubagentsSettings, saveAndEmitChanged } from "./settings.js"; +import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type ResultPreviewMode, type SubagentType } from "./types.js"; import { type AgentActivity, type AgentDetails, @@ -133,8 +133,35 @@ function escapeXml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } -/** Format a structured task notification matching Claude Code's XML. */ -function formatTaskNotification(record: AgentRecord, resultMaxLen: number): string { +// Collapsed preview line limit - matches pi's read/write tool precedent +const COLLAPSED_PREVIEW_LINES = 10; +const PLAIN_MODE_EXPANDED_LINE_CAP = 30; // Preserves upstream renderer's expanded-mode line cap. Plain mode is the backward-compatibility path; markdown mode is uncapped per spec. +const PLAIN_MODE_COLLAPSED_CHAR_CAP = 80; // Preserves upstream renderer's first-line preview char cap. Plain mode is the backward-compatibility path. +const DEFAULT_FAILURE_PREVIEW_MAX_CHARS = 65536; // 64 KiB at ASCII. + +/** Truncate to maxChars UTF-16 code units, never splitting a surrogate pair. */ +function safeTruncate(s: string, maxChars: number): string { + if (s.length <= maxChars) return s; + const high = s.charCodeAt(maxChars - 1); + return high >= 0xD800 && high <= 0xDBFF ? s.slice(0, maxChars - 1) : s.slice(0, maxChars); +} + +/** Build the preview body shared by XML payload + UI details. Caps failure-mode bodies; success/aborted/steered uncapped. */ +function buildResultPreview(record: AgentRecord, settings: SubagentsSettings): string { + const body = record.result ?? record.error ?? ""; + if (!body) return "No output."; + const isFailure = record.status === "error" || record.status === "stopped"; + if (isFailure && typeof settings.failurePreviewMaxChars !== "number") { + throw new Error("buildResultPreview: failurePreviewMaxChars must be a number on failure status"); + } + const cap = isFailure ? (settings.failurePreviewMaxChars as number) : Number.POSITIVE_INFINITY; + return body.length > cap + ? safeTruncate(body, cap) + "\n…(truncated, see transcript)" + : body; +} + +/** @internal Format a structured task notification matching Claude Code's XML. */ +export function formatTaskNotification(record: AgentRecord, settings: SubagentsSettings): string { const status = getStatusLabel(record.status, record.error); const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0; const totalTokens = getLifetimeTotal(record.lifetimeUsage); @@ -142,11 +169,7 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): stri const ctxXml = contextPercent !== null ? `${Math.round(contextPercent)}` : ""; const compactXml = record.compactionCount ? `${record.compactionCount}` : ""; - const resultPreview = record.result - ? record.result.length > resultMaxLen - ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)" - : record.result - : "No output."; + const resultPreview = buildResultPreview(record, settings); return [ ``, @@ -182,10 +205,12 @@ function buildDetails( }; } -/** Build notification details for the custom message renderer. */ -function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails { +/** @internal Build notification details for the custom message renderer. */ +export function buildNotificationDetails(record: AgentRecord, settings: SubagentsSettings, activity?: AgentActivity): NotificationDetails { const totalTokens = getLifetimeTotal(record.lifetimeUsage); + const resultPreview = buildResultPreview(record, settings); + return { id: record.id, description: record.description, @@ -197,61 +222,107 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act durationMs: record.completedAt ? record.completedAt - record.startedAt : 0, outputFile: record.outputFile, error: record.error, - resultPreview: record.result - ? record.result.length > resultMaxLen - ? record.result.slice(0, resultMaxLen) + "…" - : record.result - : "No output.", + resultPreview, }; } -export default function (pi: ExtensionAPI) { - // ---- Register custom notification renderer ---- - pi.registerMessageRenderer( - "subagent-notification", - (message, { expanded }, theme) => { - const d = message.details; - if (!d) return undefined; +/** @internal Render notification header with icon, description, status, and stats. */ +export function subagentNotificationRenderHeader(d: NotificationDetails, theme: any): any { + const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted"; + const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); + const statusText = isError ? d.status + : d.status === "steered" ? "completed (steered)" + : "completed"; + + // Line 1: icon + agent description + status + let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`; + + // Line 2: stats + const parts: string[] = []; + if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns)); + if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`); + if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens)); + if (d.durationMs > 0) parts.push(formatMs(d.durationMs)); + if (parts.length) { + line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " "); + } - function renderOne(d: NotificationDetails): string { - const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted"; - const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); - const statusText = isError ? d.status - : d.status === "steered" ? "completed (steered)" - : "completed"; + return new Text(line, 0, 0); +} - // Line 1: icon + agent description + status - let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`; +/** @internal Render notification body with markdown or plain mode dispatch. */ +export function subagentNotificationRenderBody(d: NotificationDetails, expanded: boolean, mode: ResultPreviewMode, theme: any): any { + if (mode === "markdown") { + let body = d.resultPreview; + if (!expanded) { + const lines = body.split("\n"); + if (lines.length > COLLAPSED_PREVIEW_LINES) { + const remaining = lines.length - COLLAPSED_PREVIEW_LINES; + body = lines.slice(0, COLLAPSED_PREVIEW_LINES).join("\n") + `\n… (${remaining} more lines, ctrl+O to expand)`; + } + } + + const container = new Container(); + if (body.trim()) { + container.addChild(new Markdown(body, 2, 0, getMarkdownTheme())); + } + if (d.outputFile) { + container.addChild(new Text(theme.fg("muted", ` transcript: ${d.outputFile}`), 0, 0)); + } + return container; + } else { + let bodyText = ""; + if (expanded) { + const lines = d.resultPreview.split("\n").slice(0, PLAIN_MODE_EXPANDED_LINE_CAP); + for (const l of lines) bodyText += (bodyText ? "\n" : "") + theme.fg("dim", ` ${l}`); + } else { + const preview = d.resultPreview.split("\n")[0]?.slice(0, PLAIN_MODE_COLLAPSED_CHAR_CAP) ?? ""; + bodyText = theme.fg("dim", ` ⎿ ${preview}`); + } - // Line 2: stats - const parts: string[] = []; - if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns)); - if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`); - if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens)); - if (d.durationMs > 0) parts.push(formatMs(d.durationMs)); - if (parts.length) { - line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " "); - } + if (d.outputFile) { + bodyText += (bodyText ? "\n" : "") + theme.fg("muted", ` transcript: ${d.outputFile}`); + } - // Line 3: result preview (collapsed) or full (expanded) - if (expanded) { - const lines = d.resultPreview.split("\n").slice(0, 30); - for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`); - } else { - const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? ""; - line += "\n " + theme.fg("dim", `⎿ ${preview}`); - } + return new Text(bodyText, 0, 0); + } +} - // Line 4: output file link (if present) - if (d.outputFile) { - line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`); - } +/** @internal Main subagent notification renderer. */ +export function subagentNotificationRenderer(message: { details?: NotificationDetails }, options: { expanded: boolean }, theme: any, resultPreviewMode: ResultPreviewMode, resultPreviewExpanded: boolean): any { + const d = message.details; + if (!d) return undefined; - return line; - } + const effectiveExpanded = resultPreviewExpanded ? true : options.expanded; - const all = [d, ...(d.others ?? [])]; - return new Text(all.map(renderOne).join("\n"), 0, 0); + function renderOne(d: NotificationDetails): any { + const header = subagentNotificationRenderHeader(d, theme); + const body = subagentNotificationRenderBody(d, effectiveExpanded, resultPreviewMode, theme); + const container = new Container(); + container.addChild(header); + container.addChild(body); + return container; + } + + const all = [d, ...(d.others ?? [])]; + if (all.length === 1) { + return renderOne(all[0]); + } else { + const container = new Container(); + for (let i = 0; i < all.length; i++) { + if (i > 0) container.addChild(new Spacer()); + container.addChild(renderOne(all[i])); + } + return container; + } +} + +export default function (pi: ExtensionAPI) { + // ---- Register custom notification renderer ---- + pi.registerMessageRenderer( + "subagent-notification", + (message, { expanded }, theme) => { + return subagentNotificationRenderer(message, { expanded }, theme, resultPreviewMode, resultPreviewExpanded); } ); @@ -293,14 +364,14 @@ export default function (pi: ExtensionAPI) { function emitIndividualNudge(record: AgentRecord) { if (record.resultConsumed) return; // re-check at send time - const notification = formatTaskNotification(record, 500); + const notification = formatTaskNotification(record, { failurePreviewMaxChars }); const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : ''; pi.sendMessage({ customType: "subagent-notification", content: notification + footer, display: true, - details: buildNotificationDetails(record, 500, agentActivity.get(record.id)), + details: buildNotificationDetails(record, { failurePreviewMaxChars }, agentActivity.get(record.id)), }, { deliverAs: "followUp", triggerTurn: true }); } @@ -322,15 +393,15 @@ export default function (pi: ExtensionAPI) { const unconsumed = records.filter(r => !r.resultConsumed); if (unconsumed.length === 0) { widget.update(); return; } - const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n'); + const notifications = unconsumed.map(r => formatTaskNotification(r, { failurePreviewMaxChars })).join('\n\n'); const label = partial ? `${unconsumed.length} agent(s) finished (partial — others still running)` : `${unconsumed.length} agent(s) finished`; const [first, ...rest] = unconsumed; - const details = buildNotificationDetails(first, 300, agentActivity.get(first.id)); + const details = buildNotificationDetails(first, { failurePreviewMaxChars }, agentActivity.get(first.id)); if (rest.length > 0) { - details.others = rest.map(r => buildNotificationDetails(r, 300, agentActivity.get(r.id))); + details.others = rest.map(r => buildNotificationDetails(r, { failurePreviewMaxChars }, agentActivity.get(r.id))); } pi.sendMessage({ @@ -530,6 +601,14 @@ export default function (pi: ExtensionAPI) { function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; } function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; } + // ---- Result preview configuration ---- + let resultPreviewMode: ResultPreviewMode = "markdown"; + let resultPreviewExpanded = true; + let failurePreviewMaxChars = DEFAULT_FAILURE_PREVIEW_MAX_CHARS; + function setResultPreviewMode(mode: ResultPreviewMode): void { resultPreviewMode = mode; } + function setResultPreviewExpanded(expanded: boolean): void { resultPreviewExpanded = expanded; } + function setFailurePreviewMaxChars(chars: number): void { failurePreviewMaxChars = chars; } + // ---- Batch tracking for smart join mode ---- // Collects background agent IDs spawned in the current turn for smart grouping. // Uses a debounced timer: each new agent resets the 100ms window so that all @@ -622,6 +701,9 @@ export default function (pi: ExtensionAPI) { setDefaultJoinMode, setSchedulingEnabled, setScopeModels: setScopeModelsEnabled, + setResultPreviewMode, + setResultPreviewExpanded, + setFailurePreviewMaxChars, }, (event, payload) => pi.events.emit(event, payload), ); @@ -1860,10 +1942,13 @@ ${systemPrompt} defaultJoinMode: getDefaultJoinMode(), schedulingEnabled: isSchedulingEnabled(), scopeModels: isScopeModelsEnabled(), + resultPreviewMode, + resultPreviewExpanded, + failurePreviewMaxChars, }; } - const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns"]); + const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns", "failurePreviewMaxChars"]); async function showSettings(ctx: ExtensionCommandContext) { function buildItems(): SettingItem[] { @@ -1914,6 +1999,27 @@ ${systemPrompt} currentValue: isScopeModelsEnabled() ? "on" : "off", values: ["on", "off"], }, + { + id: "resultPreviewMode", + label: "Result preview mode", + description: "Render result body as markdown or plain text", + currentValue: resultPreviewMode, + values: ["markdown", "plain"], + }, + { + id: "resultPreviewExpanded", + label: "Result preview expanded by default", + description: "Always show expanded result preview, ignoring pi's expanded flag", + currentValue: resultPreviewExpanded ? "on" : "off", + values: ["on", "off"], + }, + { + id: "failurePreviewMaxChars", + label: "Failure preview max chars", + description: "Max chars for failure preview before truncation (Enter to type)", + currentValue: String(failurePreviewMaxChars), + values: [String(failurePreviewMaxChars)], + }, ]; } @@ -1958,6 +2064,19 @@ ${systemPrompt} const enabled = value === "on"; setScopeModelsEnabled(enabled); notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`); + } else if (id === "resultPreviewMode") { + setResultPreviewMode(value as ResultPreviewMode); + notifyApplied(ctx, `Result preview mode set to ${value}`); + } else if (id === "resultPreviewExpanded") { + const expanded = value === "on"; + setResultPreviewExpanded(expanded); + notifyApplied(ctx, `Result preview expanded by default ${expanded ? "enabled" : "disabled"}`); + } else if (id === "failurePreviewMaxChars") { + const n = parseInt(value, 10); + if (n >= 1 && n <= RESULT_PREVIEW_MAX_CHARS_CEILING) { + setFailurePreviewMaxChars(n); + notifyApplied(ctx, `Failure preview max chars set to ${n}`); + } } } diff --git a/src/settings.ts b/src/settings.ts index 9ab239e5..18d75704 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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; @@ -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. */ @@ -58,12 +64,16 @@ 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 = new Set(["async", "group", "smart"]); +const VALID_RESULT_PREVIEW_MODES: ReadonlySet = new Set(["plain", "markdown"]); // Sanity ceilings — prevent hand-edited configs from asking for values that // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough @@ -71,6 +81,7 @@ const VALID_JOIN_MODES: ReadonlySet = new Set(["async", "group 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 { @@ -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; } @@ -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); } /** diff --git a/src/types.ts b/src/types.ts index bc2acce6..635de6a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; diff --git a/test/notification-details.test.ts b/test/notification-details.test.ts new file mode 100644 index 00000000..4e525ca1 --- /dev/null +++ b/test/notification-details.test.ts @@ -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 => ({ + 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)"); + }); +}); \ No newline at end of file diff --git a/test/notification-format.test.ts b/test/notification-format.test.ts new file mode 100644 index 00000000..08d8235c --- /dev/null +++ b/test/notification-format.test.ts @@ -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 => ({ + 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>/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>/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>/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("No output."); + 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("Error message"); + expect(stoppedXml).toContain("Stopped"); + expect(abortedXml).toContain("Partial result"); + }); +}); \ No newline at end of file diff --git a/test/renderer-markdown.test.ts b/test/renderer-markdown.test.ts new file mode 100644 index 00000000..720808ee --- /dev/null +++ b/test/renderer-markdown.test.ts @@ -0,0 +1,167 @@ +import { Container, Markdown, Text } from "@earendil-works/pi-tui"; +import { describe, expect, it } from "vitest"; +import { subagentNotificationRenderBody, subagentNotificationRenderer } from "../src/index.js"; +import type { NotificationDetails } from "../src/types.js"; + +// Mock theme +const mockTheme = { + fg: (color: string, text: string) => `[${color}]${text}[/${color}]`, + bold: (text: string) => `**${text}**`, +}; + +describe("markdown rendering branch", () => { + const createDetails = (overrides: Partial = {}): NotificationDetails => ({ + id: "test-1", + description: "Test Agent", + status: "completed", + toolUses: 2, + turnCount: 3, + totalTokens: 150, + durationMs: 5000, + resultPreview: "# Test Result\n\nThis is a test result with multiple lines.\nLine 2\nLine 3", + ...overrides, + }); + + it("markdown mode + expanded produces Markdown component", () => { + const details = createDetails(); + const body = subagentNotificationRenderBody(details, true, "markdown", mockTheme); + + expect(body).toBeInstanceOf(Container); + const container = body as Container; + expect(container.children.length).toBeGreaterThan(0); + expect(container.children[0]).toBeInstanceOf(Markdown); + }); + + it("markdown mode + collapsed caps at 10 lines with expand hint", () => { + const fiftyLines = Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`).join("\n"); + const details = createDetails({ resultPreview: fiftyLines }); + + const body = subagentNotificationRenderBody(details, false, "markdown", mockTheme); + const container = body as Container; + const markdown = container.children[0] as Markdown; + + // Check that the markdown content includes the expand hint + expect(markdown.text).toContain("… (40 more lines, ctrl+O to expand)"); + }); + + it("collapse boundary at exactly 10 lines shows no hint", () => { + const tenLines = Array.from({ length: 10 }, (_, i) => `Line ${i + 1}`).join("\n"); + const details = createDetails({ resultPreview: tenLines }); + const body = subagentNotificationRenderBody(details, false, "markdown", mockTheme); + const container = body as Container; + const markdown = container.children[0] as Markdown; + expect(markdown.text).not.toContain("more lines"); + }); + + it("collapse boundary at 11 lines shows hint", () => { + const elevenLines = Array.from({ length: 11 }, (_, i) => `Line ${i + 1}`).join("\n"); + const details = createDetails({ resultPreview: elevenLines }); + const body = subagentNotificationRenderBody(details, false, "markdown", mockTheme); + const container = body as Container; + const markdown = container.children[0] as Markdown; + expect(markdown.text).toContain("(1 more lines, ctrl+O to expand)"); + }); + + it("plain mode expanded produces byte-equivalent to baseline", () => { + const details = createDetails(); + const body = subagentNotificationRenderBody(details, true, "plain", mockTheme); + + expect(body).toBeInstanceOf(Text); + const text = body as Text; + expect(text.text).toContain("[dim] # Test Result[/dim]"); + expect(text.text).toContain("[dim] This is a test result with multiple lines.[/dim]"); + }); + + it("plain mode collapsed produces byte-equivalent to baseline", () => { + const details = createDetails({ + resultPreview: "This is a long line that should be truncated at 80 characters for preview", + }); + const body = subagentNotificationRenderBody(details, false, "plain", mockTheme); + + expect(body).toBeInstanceOf(Text); + const text = body as Text; + expect(text.text).toContain("[dim] ⎿ This is a long line that should be truncated at 80 characters for preview[/dim]"); + }); + + it("resultPreviewExpanded: true ignores collapsed expand=false flag, renders all 50 lines", () => { + const fiftyLines = Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`).join("\n"); + const details = createDetails({ resultPreview: fiftyLines }); + + const body = subagentNotificationRenderBody(details, true, "markdown", mockTheme); + const container = body as Container; + const markdown = container.children[0] as Markdown; + + expect(markdown.text).toContain("Line 50"); + expect(markdown.text).not.toContain("more lines, ctrl+O to expand"); + }); + + it("resultPreviewExpanded: false honors collapsed expand=false flag, shows hint", () => { + const fiftyLines = Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`).join("\n"); + const details = createDetails({ resultPreview: fiftyLines }); + + const body = subagentNotificationRenderBody(details, false, "markdown", mockTheme); + const container = body as Container; + const markdown = container.children[0] as Markdown; + + expect(markdown.text).not.toContain("Line 50"); + expect(markdown.text).toContain("more lines, ctrl+O to expand"); + }); + + it("empty body + markdown mode does not throw", () => { + const details = createDetails({ resultPreview: "" }); + + expect(() => subagentNotificationRenderBody(details, true, "markdown", mockTheme)).not.toThrow(); + }); + + it("code fence cut mid-block in collapsed mode", () => { + const lines = [ + "Line 1", "Line 2", "Line 3", "Line 4", "Line 5", "Line 6", "Line 7", + "```javascript", // Fence opens at line 8 + "const x = 1;", "const y = 2;", "const z = 3;", "const a = 4;", "const b = 5;", // Lines 9-13: code content + "```", // Fence closes at line 14 + "Line 15" + ]; + const longResult = lines.join("\n"); + const details = createDetails({ resultPreview: longResult }); + + const body = subagentNotificationRenderBody(details, false, "markdown", mockTheme); + + // Truncation at line 10 cuts the fence mid-block (only 2 lines of code visible) + expect(body).toBeInstanceOf(Container); + const container = body as Container; + const markdown = container.children[0] as Markdown; + expect(markdown.text).toContain("```javascript"); + expect(markdown.text).toContain("const x = 1;"); + expect(markdown.text).toContain("const y = 2;"); + expect(markdown.text).not.toContain("const z = 3;"); // Third line of code should be truncated + expect(markdown.text).not.toContain("```\nLine 15"); // Closing fence + content should not be present + expect(markdown.text).toContain("(5 more lines, ctrl+O to expand)"); // Expand hint should be appended + }); + + it("mixed success/failure in d.others renders correctly", () => { + const main = createDetails({ status: "completed" }); + const failed = createDetails({ status: "error", id: "test-2", resultPreview: "Error occurred" }); + const stopped = createDetails({ status: "stopped", id: "test-3", resultPreview: "No output." }); + main.others = [failed, stopped]; + + const rendered = subagentNotificationRenderer( + { details: main }, + { expanded: true }, + mockTheme, + "markdown", + false + ); + + expect(rendered).toBeInstanceOf(Container); + expect(rendered.children).toHaveLength(5); // 3 agents + 2 spacers + + // Verify each renders appropriately for its status + const mainContainer = rendered.children[0] as Container; + const failedContainer = rendered.children[2] as Container; + const stoppedContainer = rendered.children[4] as Container; + + expect(mainContainer).toBeInstanceOf(Container); + expect(failedContainer).toBeInstanceOf(Container); + expect(stoppedContainer).toBeInstanceOf(Container); + }); +}); \ No newline at end of file diff --git a/test/renderer-regression.test.ts b/test/renderer-regression.test.ts new file mode 100644 index 00000000..4504a314 --- /dev/null +++ b/test/renderer-regression.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { subagentNotificationRenderer } from "../src/index.js"; +import type { NotificationDetails } from "../src/types.js"; + +// Mock theme for consistent output +const mockTheme = { + fg: (color: string, text: string) => `[${color}]${text}[/${color}]`, + bold: (text: string) => `**${text}**`, +}; + +describe("renderer regression-lock snapshot", () => { + const createDetails = (overrides: Partial = {}): NotificationDetails => ({ + id: "test-1", + description: "Test Agent", + status: "completed", + toolUses: 2, + turnCount: 3, + totalTokens: 150, + durationMs: 5000, + resultPreview: "# Test Result\n\nThis is a test result with multiple lines.\nLine 2\nLine 3", + ...overrides, + }); + + // Helper to extract rendered text from Container/Text components + function extractRenderedText(component: any): string { + if (!component) return ""; + if (typeof component.text === "string") return component.text; + if (component.children && Array.isArray(component.children)) { + return component.children.map(extractRenderedText).join(""); + } + return ""; + } + + it("plain mode rendering produces consistent ANSI output", () => { + const testCases = [ + { status: "completed", preview: "Success result" }, + { status: "error", preview: "Error: failed" }, + { status: "stopped", preview: "No output." }, + { status: "aborted", preview: "Partial result" }, + { status: "steered", preview: "Steered result" }, + ]; + + const outputs = testCases.map(({ status, preview }) => { + const details = createDetails({ status: status as any, resultPreview: preview }); + const rendered = subagentNotificationRenderer( + { details }, + { expanded: true }, + mockTheme, + "plain", + false + ); + return `${status}: ${extractRenderedText(rendered)}`; + }); + + expect(outputs).toMatchInlineSnapshot(` + [ + "completed: [success]✓[/success] **Test Agent** [dim]completed[/dim] + [dim]↻3[/dim] [dim]·[/dim] [dim]2 tool uses[/dim] [dim]·[/dim] [dim]150 token[/dim] [dim]·[/dim] [dim]5.0s[/dim][dim] Success result[/dim]", + "error: [error]✗[/error] **Test Agent** [dim]error[/dim] + [dim]↻3[/dim] [dim]·[/dim] [dim]2 tool uses[/dim] [dim]·[/dim] [dim]150 token[/dim] [dim]·[/dim] [dim]5.0s[/dim][dim] Error: failed[/dim]", + "stopped: [error]✗[/error] **Test Agent** [dim]stopped[/dim] + [dim]↻3[/dim] [dim]·[/dim] [dim]2 tool uses[/dim] [dim]·[/dim] [dim]150 token[/dim] [dim]·[/dim] [dim]5.0s[/dim][dim] No output.[/dim]", + "aborted: [error]✗[/error] **Test Agent** [dim]aborted[/dim] + [dim]↻3[/dim] [dim]·[/dim] [dim]2 tool uses[/dim] [dim]·[/dim] [dim]150 token[/dim] [dim]·[/dim] [dim]5.0s[/dim][dim] Partial result[/dim]", + "steered: [success]✓[/success] **Test Agent** [dim]completed (steered)[/dim] + [dim]↻3[/dim] [dim]·[/dim] [dim]2 tool uses[/dim] [dim]·[/dim] [dim]150 token[/dim] [dim]·[/dim] [dim]5.0s[/dim][dim] Steered result[/dim]", + ] + `); + }); + + it("snapshot: group rendering with d.others populated", () => { + // 2 agents: completed + completed + const completedCompleted = createDetails({ + description: "Main Agent", + resultPreview: "Main result", + others: [ + { id: "agent-2", description: "Second Agent", status: "completed" as const, toolUses: 1, turnCount: 2, totalTokens: 100, durationMs: 3000, resultPreview: "Second result" } + ] + }); + const rendered1 = subagentNotificationRenderer({ details: completedCompleted }, { expanded: true }, mockTheme, "plain", false); + expect(extractRenderedText(rendered1)).toMatchInlineSnapshot(` + "[success]✓[/success] **Main Agent** [dim]completed[/dim] + [dim]↻3[/dim] [dim]·[/dim] [dim]2 tool uses[/dim] [dim]·[/dim] [dim]150 token[/dim] [dim]·[/dim] [dim]5.0s[/dim][dim] Main result[/dim][success]✓[/success] **Second Agent** [dim]completed[/dim] + [dim]↻2[/dim] [dim]·[/dim] [dim]1 tool use[/dim] [dim]·[/dim] [dim]100 token[/dim] [dim]·[/dim] [dim]3.0s[/dim][dim] Second result[/dim]" + `); + + // 2 agents: completed + error (mixed) + const completedError = createDetails({ + description: "Main Agent", + resultPreview: "Main result", + others: [ + { id: "agent-2", description: "Failed Agent", status: "error" as const, toolUses: 0, turnCount: 1, totalTokens: 50, durationMs: 1000, resultPreview: "Error occurred" } + ] + }); + const rendered2 = subagentNotificationRenderer({ details: completedError }, { expanded: true }, mockTheme, "plain", false); + expect(extractRenderedText(rendered2)).toMatchInlineSnapshot(` + "[success]✓[/success] **Main Agent** [dim]completed[/dim] + [dim]↻3[/dim] [dim]·[/dim] [dim]2 tool uses[/dim] [dim]·[/dim] [dim]150 token[/dim] [dim]·[/dim] [dim]5.0s[/dim][dim] Main result[/dim][error]✗[/error] **Failed Agent** [dim]error[/dim] + [dim]↻1[/dim] [dim]·[/dim] [dim]50 token[/dim] [dim]·[/dim] [dim]1.0s[/dim][dim] Error occurred[/dim]" + `); + + // 3 agents: completed + steered + aborted (all success-ish) + const mixedSuccess = createDetails({ + description: "Main Agent", + resultPreview: "Main result", + others: [ + { id: "agent-2", description: "Steered Agent", status: "steered" as const, toolUses: 3, turnCount: 4, totalTokens: 200, durationMs: 6000, resultPreview: "Steered result" }, + { id: "agent-3", description: "Aborted Agent", status: "aborted" as const, toolUses: 1, turnCount: 2, totalTokens: 75, durationMs: 2000, resultPreview: "Partial result" } + ] + }); + const rendered3 = subagentNotificationRenderer({ details: mixedSuccess }, { expanded: true }, mockTheme, "plain", false); + expect(extractRenderedText(rendered3)).toMatchInlineSnapshot(` + "[success]✓[/success] **Main Agent** [dim]completed[/dim] + [dim]↻3[/dim] [dim]·[/dim] [dim]2 tool uses[/dim] [dim]·[/dim] [dim]150 token[/dim] [dim]·[/dim] [dim]5.0s[/dim][dim] Main result[/dim][success]✓[/success] **Steered Agent** [dim]completed (steered)[/dim] + [dim]↻4[/dim] [dim]·[/dim] [dim]3 tool uses[/dim] [dim]·[/dim] [dim]200 token[/dim] [dim]·[/dim] [dim]6.0s[/dim][dim] Steered result[/dim][error]✗[/error] **Aborted Agent** [dim]aborted[/dim] + [dim]↻2[/dim] [dim]·[/dim] [dim]1 tool use[/dim] [dim]·[/dim] [dim]75 token[/dim] [dim]·[/dim] [dim]2.0s[/dim][dim] Partial result[/dim]" + `); + }); + +}); \ No newline at end of file diff --git a/test/result-preview-settings.test.ts b/test/result-preview-settings.test.ts new file mode 100644 index 00000000..38f4bf04 --- /dev/null +++ b/test/result-preview-settings.test.ts @@ -0,0 +1,182 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { applySettings, loadSettings, type SettingsAppliers, saveSettings } from "../src/settings.js"; + +describe("result preview settings sanitizer + persistence", () => { + let globalDir: string; + let projectDir: string; + let originalAgentDirEnv: string | undefined; + + const globalFile = () => join(globalDir, "subagents.json"); + const projectFile = () => join(projectDir, ".pi", "subagents.json"); + + beforeEach(() => { + globalDir = mkdtempSync(join(tmpdir(), "pi-settings-global-")); + projectDir = mkdtempSync(join(tmpdir(), "pi-settings-project-")); + originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = globalDir; + }); + + afterEach(() => { + if (originalAgentDirEnv == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = originalAgentDirEnv; + rmSync(globalDir, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + }); + + function writeGlobal(obj: unknown) { + writeFileSync(globalFile(), JSON.stringify(obj)); + } + + function writeProject(obj: unknown) { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(projectFile(), JSON.stringify(obj)); + } + + describe("resultPreviewMode sanitizer", () => { + it("accepts valid values", () => { + writeProject({ resultPreviewMode: "plain" }); + expect(loadSettings(projectDir).resultPreviewMode).toBe("plain"); + + writeProject({ resultPreviewMode: "markdown" }); + expect(loadSettings(projectDir).resultPreviewMode).toBe("markdown"); + }); + + it("drops invalid values", () => { + writeProject({ resultPreviewMode: "foo" }); + expect(loadSettings(projectDir).resultPreviewMode).toBeUndefined(); + + writeProject({ resultPreviewMode: null }); + expect(loadSettings(projectDir).resultPreviewMode).toBeUndefined(); + + writeProject({ resultPreviewMode: 42 }); + expect(loadSettings(projectDir).resultPreviewMode).toBeUndefined(); + + writeProject({ resultPreviewMode: "undefined" }); + expect(loadSettings(projectDir).resultPreviewMode).toBeUndefined(); + }); + }); + + describe("resultPreviewExpanded sanitizer", () => { + it("accepts boolean values", () => { + writeProject({ resultPreviewExpanded: true }); + expect(loadSettings(projectDir).resultPreviewExpanded).toBe(true); + + writeProject({ resultPreviewExpanded: false }); + expect(loadSettings(projectDir).resultPreviewExpanded).toBe(false); + }); + + it("drops non-boolean values", () => { + writeProject({ resultPreviewExpanded: "true" }); + expect(loadSettings(projectDir).resultPreviewExpanded).toBeUndefined(); + + writeProject({ resultPreviewExpanded: 1 }); + expect(loadSettings(projectDir).resultPreviewExpanded).toBeUndefined(); + + writeProject({ resultPreviewExpanded: null }); + expect(loadSettings(projectDir).resultPreviewExpanded).toBeUndefined(); + }); + }); + + describe("failurePreviewMaxChars sanitizer", () => { + it("accepts valid integers in range", () => { + writeProject({ failurePreviewMaxChars: 1 }); + expect(loadSettings(projectDir).failurePreviewMaxChars).toBe(1); + + writeProject({ failurePreviewMaxChars: 65536 }); + expect(loadSettings(projectDir).failurePreviewMaxChars).toBe(65536); + + writeProject({ failurePreviewMaxChars: 1048576 }); + expect(loadSettings(projectDir).failurePreviewMaxChars).toBe(1048576); + }); + + it("drops out-of-range values", () => { + writeProject({ failurePreviewMaxChars: 0 }); + expect(loadSettings(projectDir).failurePreviewMaxChars).toBeUndefined(); + + writeProject({ failurePreviewMaxChars: -1 }); + expect(loadSettings(projectDir).failurePreviewMaxChars).toBeUndefined(); + + writeProject({ failurePreviewMaxChars: 2000000 }); + expect(loadSettings(projectDir).failurePreviewMaxChars).toBeUndefined(); + }); + + it("drops non-integer values", () => { + writeProject({ failurePreviewMaxChars: 1.5 }); + expect(loadSettings(projectDir).failurePreviewMaxChars).toBeUndefined(); + + writeProject({ failurePreviewMaxChars: NaN }); + expect(loadSettings(projectDir).failurePreviewMaxChars).toBeUndefined(); + + writeProject({ failurePreviewMaxChars: "1000" }); + expect(loadSettings(projectDir).failurePreviewMaxChars).toBeUndefined(); + }); + }); + + describe("persistence roundtrip", () => { + it("writes and reloads all three fields", () => { + const settings = { + resultPreviewMode: "plain" as const, + resultPreviewExpanded: false, + failurePreviewMaxChars: 32768, + }; + + expect(saveSettings(settings, projectDir)).toBe(true); + expect(existsSync(projectFile())).toBe(true); + + const reloaded = loadSettings(projectDir); + expect(reloaded.resultPreviewMode).toBe("plain"); + expect(reloaded.resultPreviewExpanded).toBe(false); + expect(reloaded.failurePreviewMaxChars).toBe(32768); + }); + }); + + describe("project precedence", () => { + it("project overrides global on each field", () => { + writeGlobal({ + resultPreviewMode: "plain", + resultPreviewExpanded: true, + failurePreviewMaxChars: 1000, + }); + + writeProject({ + resultPreviewMode: "markdown", + resultPreviewExpanded: false, + failurePreviewMaxChars: 2000, + }); + + const merged = loadSettings(projectDir); + expect(merged.resultPreviewMode).toBe("markdown"); + expect(merged.resultPreviewExpanded).toBe(false); + expect(merged.failurePreviewMaxChars).toBe(2000); + }); + }); + + describe("applySettings wiring", () => { + it("applySettings calls correct setter functions", () => { + const mockAppliers: SettingsAppliers = { + setMaxConcurrent: vi.fn(), + setDefaultMaxTurns: vi.fn(), + setGraceTurns: vi.fn(), + setDefaultJoinMode: vi.fn(), + setSchedulingEnabled: vi.fn(), + setScopeModels: vi.fn(), + setResultPreviewMode: vi.fn(), + setResultPreviewExpanded: vi.fn(), + setFailurePreviewMaxChars: vi.fn(), + }; + + applySettings({ + resultPreviewMode: "plain", + resultPreviewExpanded: false, + failurePreviewMaxChars: 1000, + }, mockAppliers); + + expect(mockAppliers.setResultPreviewMode).toHaveBeenCalledWith("plain"); + expect(mockAppliers.setResultPreviewExpanded).toHaveBeenCalledWith(false); + expect(mockAppliers.setFailurePreviewMaxChars).toHaveBeenCalledWith(1000); + }); + }); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index a5cba40b..9b07cbb9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "skipLibCheck": true, "outDir": "dist", "rootDir": "src", - "declaration": true + "declaration": true, + "stripInternal": true }, "include": ["src/**/*.ts"] }