feat(coding-agent): add bug reporting

This commit is contained in:
Armin Ronacher 2026-09-19 17:20:54 +02:00
parent 36b60d2e89
commit 3c75b27479
20 changed files with 1092 additions and 28 deletions

View file

@ -12,6 +12,7 @@
- Added per-model `reserveTokens` and `keepRecentTokens` settings through `compaction.modelOverrides`, with ordinary compaction settings as fallback ([#8133](https://github.com/earendil-works/pi-mono/issues/8133)).
- Added `compat.allowedFallbackModels` configuration for overriding or disabling Anthropic server-side fallback models ([#9294](https://github.com/earendil-works/pi/issues/9294)).
- Added an unsubscribe function from `pi.on()` so extensions can drop event handlers. Handlers added or removed during a dispatch apply to later dispatches, not the current one ([#8967](https://github.com/earendil-works/pi/issues/8967)).
- Added `/bug [description]` to report a bug to the Pi developers. The report bundles environment, model, provider, extension, and settings metadata (secrets redacted), assistant message diagnostics from the session, optionally the session transcript, or a model-written summary of what went wrong instead. It is uploaded to Radius (no login required; attributed when logged in) or exported as a zip archive, and the report id is recorded in the session as a `pi.bug-report` entry. Crashes are recorded in `~/.pi/agent/crashes.json`, announced once on the next start, and attached to the next report; unexplained errors and exhausted retries point at `/bug` once per session.
### Changed

View file

@ -195,6 +195,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
| `/export [file]` | Export session to HTML or JSONL file |
| `/import <file>` | Import and resume a session from a JSONL file |
| `/share` | Upload as private GitHub gist with shareable HTML link |
| `/bug [description]` | Report a bug to the Pi developers; see [Sessions](docs/sessions.md#reporting-bugs) |
| `/reload` | Reload keybindings, extensions, skills, prompts, themes, and context files |
| `/hotkeys` | Show all keyboard shortcuts |
| `/changelog` | Display version history |

View file

@ -86,6 +86,7 @@ These variables are read by Pi itself:
| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no` |
| `PI_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported |
| `PI_SHARE_VIEWER_URL` | Override the base URL used by `/share` |
| `PI_RADIUS_GATEWAY` | Override the Radius gateway origin used by `/bug` uploads and Radius relay connections |
| `PI_HARDWARE_CURSOR` | Set to `1` to show the hardware cursor; see [Terminal setup](terminal-setup.md) |
| `PI_HYPERLINKS` | Override OSC 8 hyperlink detection with `1`, `0`, or `auto` |
| `PI_IMAGE_PROTOCOL` | Override inline image detection with `kitty`, `iterm2`, `none`, or `auto` |

View file

@ -33,6 +33,7 @@ For the JSONL file format and SessionManager API, see [Session Format](session-f
| `/compact [prompt]` | Summarize older context; see [Compaction](compaction.md) |
| `/export [file]` | Export session to HTML |
| `/share` | Upload as private GitHub gist with shareable HTML link |
| `/bug [description]` | Report a bug to the Pi developers; see [Reporting Bugs](#reporting-bugs) |
## Resuming and Deleting Sessions
@ -138,6 +139,32 @@ When prompted, choose one of:
See [Compaction](compaction.md) for branch summarization internals and extension hooks.
## Reporting Bugs
`/bug [description]` collects a bug report for the Pi developers. The report is not shared publicly. The dialog asks for an optional description and whether to include the session transcript. If you decline the transcript, pi offers to have the current model write a summary of what went wrong instead; the transcript is sent to your provider with your credentials, and only the summary is attached.
The last step chooses where the report goes:
- **Upload Report** sends it to the Pi developers through `radius.pi.dev`. No login is required; if you are logged into Radius, the report is attributed to your account so the developers can follow up. If the upload fails, pi offers to export the zip instead.
- **Export as Zip** writes a zip archive to the current directory. Attach it to an issue or send it to the developers yourself.
Both contain the same files:
| File | Content |
|------|---------|
| `report.json` | pi version, runtime, OS, terminal, current model and provider configuration, loaded extensions, and settings. API keys, header values, URL credentials, and the analytics tracking id are never included. |
| `diagnostics.json` | Provider and runtime error diagnostics attached to assistant messages across the whole session (failed or aborted turns, retries, error messages), plus any recorded crashes. Always included; message content is not. |
| `session.jsonl` | The current branch of the session, only when you chose to include it. It contains file contents and command output read during the session. |
| `summary.md` | The model-written summary, only when you chose to generate one. |
Each report has a UUID. pi shows it after upload or export and records it in the session as a `pi.bug-report` entry so you can refer to it later.
Set `PI_RADIUS_GATEWAY` to upload to a different Radius deployment.
### Crashes
When pi exits because of an uncaught exception or a fatal runtime error, it stores the error message and stack trace in `~/.pi/agent/crashes.json` (the newest five). The next interactive start shows a warning once; running `/bug` attaches the stored crashes to `diagnostics.json` and removes the file after the report is uploaded or exported. Resume the crashed session with `pi -r` first if you want the transcript in the report.
## Session Format
Session files are JSONL and contain message entries, model changes, thinking-level changes, labels, compactions, branch summaries, and extension entries.

View file

@ -55,6 +55,7 @@ Type `/` in the editor to open command completion. Extensions can register custo
| `/export [file]` | Export session to HTML or JSONL |
| `/import <file>` | Import and resume a session from a JSONL file |
| `/share` | Upload as private GitHub gist with shareable HTML link |
| `/bug [description]` | Report a bug to the Pi developers; see [Sessions](sessions.md#reporting-bugs) |
| `/reload` | Reload keybindings, extensions, skills, prompts, themes, and context files |
| `/hotkeys` | Show all keyboard shortcuts |
| `/changelog` | Display version history |

View file

@ -54,6 +54,7 @@ import { sleep } from "../utils/sleep.ts";
import { normalizeToolResultImages } from "../utils/tool-result-images.ts";
import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts";
import { type BashResult, executeBashWithOperations } from "./bash-executor.ts";
import { generateBugReportSummary } from "./bug-report.ts";
import {
type CompactionPreparation,
type CompactionResult,
@ -3562,6 +3563,31 @@ export class AgentSession {
return exportSessionToJsonl(this.sessionManager, outputPath);
}
/**
* Ask the current model to describe what went wrong in this session for a bug report.
* Used when the user declines to share the transcript itself.
*/
async summarizeForBugReport(options: { hint?: string; signal: AbortSignal }): Promise<string> {
const model = this.model;
if (!model) {
throw new Error("No model selected");
}
const { model: requestModel, apiKey, headers, env } = await this._getSummarizationRequestAuth(model);
return generateBugReportSummary({
messages: this.messages,
hint: options.hint,
model: requestModel,
apiKey,
headers,
env,
signal: options.signal,
thinkingLevel: this.thinkingLevel,
streamFn: this.agent.streamFunction,
retry: this.settingsManager.getRetrySettings(),
sessionId: this.sessionId,
});
}
// =========================================================================
// Utilities
// =========================================================================

View file

@ -0,0 +1,37 @@
import type { BugReportBundle } from "./bug-report.ts";
import { bugReportFiles } from "./bug-report.ts";
import { getRadiusGatewayUrl } from "./radius.ts";
interface UploadBugReportOptions {
token?: string;
signal?: AbortSignal;
gatewayUrl?: string;
}
/** Upload a report as multipart form data, anonymously or attributed to a Radius account. */
export async function uploadBugReport(
bundle: BugReportBundle,
options: UploadBugReportOptions = {},
): Promise<{ id: string }> {
const body = new FormData();
for (const file of bugReportFiles(bundle)) {
body.append(file.name, new Blob([file.data], { type: file.contentType }), file.name);
}
const response = await fetch(new URL("/v1/bug-reports", options.gatewayUrl ?? getRadiusGatewayUrl()), {
method: "POST",
headers: options.token ? { Authorization: `Bearer ${options.token}` } : undefined,
body,
signal: options.signal,
});
const json = (await response.json().catch(() => null)) as {
ok?: boolean;
bug_report?: { id?: string };
error?: string;
description?: string;
} | null;
if (response.ok && json?.ok === true && typeof json.bug_report?.id === "string") {
return { id: json.bug_report.id };
}
const detail = json && !json.ok ? json.description || json.error : undefined;
throw new Error(`Bug report upload failed: ${detail || response.statusText || response.status}`);
}

View file

@ -0,0 +1,375 @@
import * as os from "node:os";
import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core";
import { contentText, normalizeContext, type RetryPolicy, uuidv7 } from "@earendil-works/pi-ai";
import type { Api, Model, Provider, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
import { VERSION } from "../config.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { writeZipArchive } from "../utils/zip.ts";
import { completeSummarization, estimateTokens, getSummarizationFailure } from "./compaction/compaction.ts";
import { serializeConversation } from "./compaction/utils.ts";
import type { CrashRecord } from "./crash-log.ts";
import type { Extension } from "./extensions/types.ts";
import { convertToLlm } from "./messages.ts";
import type { ModelRuntime } from "./model-runtime.ts";
import type { ReadonlySessionManager } from "./session-manager.ts";
import type { Settings } from "./settings-manager.ts";
export const BUG_REPORT_CUSTOM_ENTRY_TYPE = "pi.bug-report";
const BUG_REPORT_SCHEMA_VERSION = 1;
const REDACTED = "<redacted>";
const SENSITIVE_KEY = /(?:^|[-_])(api[-_]?key|secret|token|password|passwd|credential|authorization|cookie)(?:$|[-_])/i;
function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEY.test(key.replace(/([a-z0-9])([A-Z])/g, "$1_$2"));
}
/** Strip credentials and secret-looking query parameters from a URL. */
export function redactUrl(value: string): string {
const nested = /^([a-z][a-z0-9+.-]*:)([a-z][a-z0-9+.-]*:\/\/.*)$/i.exec(value);
if (nested) return `${nested[1]}${redactUrl(nested[2])}`;
try {
const url = new URL(value);
let changed = false;
if (url.username || url.password) {
url.username = "";
url.password = "";
changed = true;
}
for (const key of url.searchParams.keys()) {
if (isSensitiveKey(key)) {
url.searchParams.set(key, REDACTED);
changed = true;
}
}
return changed ? url.toString() : value;
} catch {
return value;
}
}
/** Copy a JSON value while removing values that may contain credentials. */
export function redactJsonValue(value: unknown): unknown {
if (value === undefined) return undefined;
return JSON.parse(
JSON.stringify(value, (key, child: unknown) => {
if (child !== null && child !== undefined && isSensitiveKey(key)) return REDACTED;
return typeof child === "string" ? redactUrl(child) : child;
}),
);
}
function redactSettings(settings: Settings): Settings {
const { trackingId: _trackingId, ...rest } = settings;
return redactJsonValue(rest) as Settings;
}
function collectEnvironment() {
const env = (name: string): string | null => process.env[name] || null;
return {
version: VERSION,
userAgent: getPiUserAgent(VERSION),
runtime: process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`,
platform: process.platform,
arch: process.arch,
osRelease: os.release(),
osVersion: os.version(),
shell: process.env.SHELL?.split(/[\\/]/).pop() || null,
terminal: {
term: env("TERM"),
program: env("TERM_PROGRAM"),
programVersion: env("TERM_PROGRAM_VERSION"),
colorterm: env("COLORTERM"),
tmux: Boolean(process.env.TMUX),
ssh: Boolean(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY),
ci: Boolean(process.env.CI),
},
// Names help diagnose configuration; values never leave the machine.
piEnvironmentVariables: Object.keys(process.env)
.filter((name) => name.startsWith("PI_"))
.sort(),
};
}
function describeModel(model: Model<Api>) {
return {
provider: model.provider,
id: model.id,
name: model.name,
api: model.api,
baseUrl: redactUrl(model.baseUrl),
reasoning: model.reasoning,
input: model.input,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
samplingParams: model.samplingParams ? redactJsonValue(model.samplingParams) : null,
compat: model.compat ? redactJsonValue(model.compat) : null,
thinkingLevelMap: model.thinkingLevelMap ?? null,
headerNames: Object.keys(model.headers ?? {}).sort(),
};
}
function describeProvider(modelRuntime: ModelRuntime, provider: Provider) {
const authTypes: Array<"api_key" | "oauth"> = [];
if (provider.auth.apiKey) authTypes.push("api_key");
if (provider.auth.oauth) authTypes.push("oauth");
return {
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl ? redactUrl(provider.baseUrl) : null,
headerNames: Object.keys(provider.headers ?? {}).sort(),
authTypes,
authStatus: modelRuntime.getProviderAuthStatus(provider.id),
usingOAuth: modelRuntime.isUsingOAuth(provider.id),
registeredByExtension: modelRuntime.getRegisteredProviderIds().includes(provider.id),
};
}
function describeExtension(extension: Extension) {
return {
path: extension.path,
source: redactUrl(extension.sourceInfo.source),
scope: extension.sourceInfo.scope,
origin: extension.sourceInfo.origin,
hidden: extension.hidden === true,
};
}
interface CollectBugReportMetadataOptions {
id?: string;
hint?: string;
sessionId: string;
cwd: string;
includeSession: boolean;
includeSummary: boolean;
messageCount: number;
model?: Model<Api>;
modelRuntime: ModelRuntime;
thinkingLevel: ThinkingLevel;
extensions: readonly Extension[];
extensionErrors: ReadonlyArray<{ path: string; error: string }>;
globalSettings: Settings;
projectSettings: Settings;
}
export function collectBugReportMetadata(options: CollectBugReportMetadataOptions) {
const hint = options.hint?.trim() || null;
const provider = options.model ? options.modelRuntime.getProvider(options.model.provider) : undefined;
return {
schemaVersion: BUG_REPORT_SCHEMA_VERSION,
id: options.id ?? uuidv7(),
createdAt: new Date().toISOString(),
hint,
environment: collectEnvironment(),
session: {
id: options.sessionId,
included: options.includeSession,
summaryIncluded: options.includeSummary,
messageCount: options.messageCount,
...(options.includeSession ? { cwd: options.cwd } : {}),
},
model: options.model ? describeModel(options.model) : null,
provider: provider ? describeProvider(options.modelRuntime, provider) : null,
thinkingLevel: options.thinkingLevel,
extensions: options.extensions.map(describeExtension),
extensionErrors: options.extensionErrors.map(({ path, error }) => ({ path, error })),
settings: {
global: redactSettings(options.globalSettings),
project: redactSettings(options.projectSettings),
},
};
}
/** Collect failed assistant turns without collecting conversation content. */
export function collectBugReportDiagnostics(
sessionManager: ReadonlySessionManager,
crashes: readonly CrashRecord[] = [],
) {
const entries = sessionManager.getEntries();
const assistant = [];
let assistantMessageCount = 0;
for (const entry of entries) {
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
assistantMessageCount++;
const message = entry.message;
const diagnostics = message.diagnostics ?? [];
if (
diagnostics.length === 0 &&
message.stopReason !== "error" &&
message.stopReason !== "aborted" &&
!message.errorMessage
) {
continue;
}
assistant.push({
entryId: entry.id,
timestamp: entry.timestamp,
provider: message.provider,
model: message.model,
api: message.api,
stopReason: message.stopReason,
...(message.rawStopReason === undefined ? {} : { rawStopReason: message.rawStopReason }),
...(message.errorMessage === undefined ? {} : { errorMessage: message.errorMessage }),
diagnostics,
});
}
return {
schemaVersion: BUG_REPORT_SCHEMA_VERSION,
sessionId: sessionManager.getSessionId(),
entryCount: entries.length,
assistantMessageCount,
assistant,
crashes: crashes.map(({ notified: _notified, ...record }) => record),
};
}
export type BugReportMetadata = ReturnType<typeof collectBugReportMetadata>;
export type BugReportDiagnostics = ReturnType<typeof collectBugReportDiagnostics>;
export interface BugReportBundle {
metadata: BugReportMetadata;
diagnostics: BugReportDiagnostics;
sessionJsonl?: string;
summary?: string;
}
export interface BugReportSessionEntryData {
id: string;
createdAt: string;
hint: string | null;
sessionIncluded: boolean;
summaryIncluded: boolean;
delivery: "zip" | "upload";
path?: string;
}
interface BugReportFile {
name: string;
contentType: string;
data: string;
}
/** Files shared by upload and zip export. */
export function bugReportFiles(bundle: BugReportBundle): BugReportFile[] {
const files: BugReportFile[] = [
{ name: "report.json", contentType: "application/json", data: `${JSON.stringify(bundle.metadata, null, 2)}\n` },
{
name: "diagnostics.json",
contentType: "application/json",
data: `${JSON.stringify(bundle.diagnostics, null, 2)}\n`,
},
];
if (bundle.sessionJsonl !== undefined) {
files.push({ name: "session.jsonl", contentType: "application/x-ndjson", data: bundle.sessionJsonl });
}
if (bundle.summary !== undefined) {
files.push({
name: "summary.md",
contentType: "text/markdown",
data: bundle.summary.endsWith("\n") ? bundle.summary : `${bundle.summary}\n`,
});
}
return files;
}
export function writeBugReportArchive(bundle: BugReportBundle, filePath: string): Promise<void> {
return writeZipArchive(filePath, bugReportFiles(bundle));
}
export function bugReportArchiveFileName(id: string): string {
return `pi-bug-report-${id}.zip`;
}
const BUG_SUMMARY_SYSTEM_PROMPT = `You are helping a user file a bug report about pi, the coding agent they are talking to. You will be shown the conversation transcript. Write a report for the pi developers describing what the user was doing and what went wrong.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the report.`;
const BUG_SUMMARY_INSTRUCTIONS = `Write the bug report in Markdown with these sections:
## What the user was doing
One short paragraph.
## What went wrong
Concrete description of the failure: wrong output, errors, hangs, tool failures, unexpected behavior. Quote error messages and tool output verbatim where they exist.
## Steps to reproduce
Numbered list, as specific as the transcript allows.
## Relevant details
Tool calls involved, files touched, model behavior, anything else that helps a developer reproduce or locate the problem.
Do not include file contents, secrets, or credentials from the transcript; refer to files by path only. Keep the report factual and concise.`;
function selectMessages(messages: readonly AgentMessage[], tokenBudget: number): AgentMessage[] {
const selected: AgentMessage[] = [];
let tokens = 0;
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
const next = estimateTokens(message);
if (selected.length > 0 && tokens + next > tokenBudget) break;
selected.push(message);
tokens += next;
}
return selected.reverse();
}
interface GenerateBugReportSummaryOptions {
messages: readonly AgentMessage[];
hint?: string;
model: Model<Api>;
apiKey?: string;
headers?: Record<string, string>;
env?: Record<string, string>;
signal: AbortSignal;
thinkingLevel?: ThinkingLevel;
streamFn?: StreamFn;
retry?: RetryPolicy;
sessionId?: string;
}
/** Ask the session model for a report when the user does not share the transcript. */
export async function generateBugReportSummary(options: GenerateBugReportSummaryOptions): Promise<string> {
const { model } = options;
const contextWindow = model.contextWindow > 0 ? model.contextWindow : 128_000;
const messages = selectMessages(options.messages, Math.floor(contextWindow * 0.6));
const hint = options.hint?.trim();
const prompt = [
messages.length < options.messages.length
? `Note: only the last ${messages.length} of ${options.messages.length} messages are shown.`
: undefined,
`<conversation>\n${serializeConversation(convertToLlm(messages))}\n</conversation>`,
hint ? `<user-report>\n${hint}\n</user-report>` : undefined,
BUG_SUMMARY_INSTRUCTIONS,
]
.filter((part) => part !== undefined)
.join("\n\n");
const requestOptions: SimpleStreamOptions = {
maxTokens: Math.min(4096, model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY),
signal: options.signal,
apiKey: options.apiKey,
headers: options.headers,
env: options.env,
sessionId: options.sessionId,
...(model.reasoning && options.thinkingLevel && options.thinkingLevel !== "off"
? { reasoning: options.thinkingLevel }
: {}),
};
const response = await completeSummarization(
model,
normalizeContext({
systemPrompt: BUG_SUMMARY_SYSTEM_PROMPT,
messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
}),
requestOptions,
options.streamFn,
options.retry,
);
if (response.stopReason === "aborted") throw new Error("Bug report summary was cancelled");
const failure = getSummarizationFailure(response, "Bug report summary");
if (failure) throw new Error(failure);
if (response.content.some((block) => block.type === "toolCall")) {
throw new Error("Bug report summary attempted to call a tool");
}
const text = contentText(response.content).trim();
if (!text) throw new Error("Bug report summary was empty");
return text;
}

View file

@ -0,0 +1,92 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getAgentDir, VERSION } from "../config.ts";
export interface CrashRecord {
timestamp: string;
version: string;
kind: "uncaught_exception" | "fatal_error";
message: string;
stack: string | null;
sessionFile: string | null;
cwd: string;
notified?: boolean;
}
const MAX_CRASH_RECORDS = 5;
const MAX_AGE = 7 * 24 * 60 * 60 * 1000;
function crashLogPath(agentDir = getAgentDir()): string {
return join(agentDir, "crashes.json");
}
export function readCrashLog(path = crashLogPath()): CrashRecord[] {
try {
const records: unknown = JSON.parse(readFileSync(path, "utf8"));
return Array.isArray(records)
? records.filter(
(record): record is CrashRecord =>
typeof record === "object" &&
record !== null &&
typeof (record as CrashRecord).timestamp === "string" &&
typeof (record as CrashRecord).message === "string",
)
: [];
} catch {
return [];
}
}
function writeCrashLog(records: readonly CrashRecord[], path: string): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(records, null, 2)}\n`);
}
/** Best-effort persistence for callers that are already crashing. */
export function recordCrash(
crash: { kind: CrashRecord["kind"]; error: unknown; sessionFile?: string; cwd: string },
path = crashLogPath(),
): CrashRecord | undefined {
try {
const { error } = crash;
const record: CrashRecord = {
timestamp: new Date().toISOString(),
version: VERSION,
kind: crash.kind,
message: error instanceof Error ? error.message || error.name : String(error),
stack: error instanceof Error && error.stack ? error.stack : null,
sessionFile: crash.sessionFile ?? null,
cwd: crash.cwd,
};
writeCrashLog([...readCrashLog(path), record].slice(-MAX_CRASH_RECORDS), path);
return record;
} catch {
return undefined;
}
}
/** Return the newest recent crash, marking pending records as announced. */
export function takeUnnotifiedCrash(path = crashLogPath(), now = Date.now()): CrashRecord | undefined {
const records = readCrashLog(path);
const crash = [...records]
.reverse()
.find((record) => !record.notified && now - Date.parse(record.timestamp) <= MAX_AGE);
if (!crash) return undefined;
try {
writeCrashLog(
records.map((record) => (record.notified ? record : { ...record, notified: true })),
path,
);
} catch {
// Showing the notice again is harmless.
}
return crash;
}
export function clearCrashLog(path = crashLogPath()): void {
try {
rmSync(path, { force: true });
} catch {
// The records can be attached again if cleanup fails.
}
}

View file

@ -1 +1,9 @@
import { DEFAULT_RADIUS_GATEWAY, normalizeRadiusGatewayUrl } from "@earendil-works/pi-ai/providers/radius-config";
export const RADIUS_PROVIDER_ID = "radius";
export const ENV_RADIUS_GATEWAY = "PI_RADIUS_GATEWAY";
/** Radius gateway origin, honoring the `PI_RADIUS_GATEWAY` override. */
export function getRadiusGatewayUrl(): string {
return normalizeRadiusGatewayUrl(process.env[ENV_RADIUS_GATEWAY] ?? DEFAULT_RADIUS_GATEWAY);
}

View file

@ -3,21 +3,13 @@ import { dirname } from "node:path";
import { resolvePath } from "../utils/paths.ts";
import { CURRENT_SESSION_VERSION, type SessionHeader, type SessionManager } from "./session-manager.ts";
/** Write the current session branch and optional trailing export-only entries as JSONL. */
export function exportSessionToJsonl(
sessionManager: SessionManager,
outputPath?: string,
createTrailingEntries?: (parentId: string | null, timestamp: string) => readonly object[],
): string {
const filePath = resolvePath(
outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`,
process.cwd(),
);
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
type TrailingEntries = (parentId: string | null, timestamp: string) => readonly object[];
/** Serialize the current branch and optional export-only entries as JSONL. */
export function serializeSessionBranch(
sessionManager: SessionManager,
createTrailingEntries?: TrailingEntries,
): string {
const timestamp = new Date().toISOString();
const header: SessionHeader = {
type: "session",
@ -26,17 +18,28 @@ export function exportSessionToJsonl(
timestamp,
cwd: sessionManager.getCwd(),
};
const lines = [JSON.stringify(header)];
const entries: object[] = [header];
let parentId: string | null = null;
for (const entry of sessionManager.getBranch()) {
lines.push(JSON.stringify({ ...entry, parentId }));
entries.push({ ...entry, parentId });
parentId = entry.id;
}
for (const entry of createTrailingEntries?.(parentId, timestamp) ?? []) {
lines.push(JSON.stringify(entry));
}
entries.push(...(createTrailingEntries?.(parentId, timestamp) ?? []));
return `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
}
writeFileSync(filePath, `${lines.join("\n")}\n`);
/** Write the current session branch and optional export-only entries as JSONL. */
export function exportSessionToJsonl(
sessionManager: SessionManager,
outputPath?: string,
createTrailingEntries?: TrailingEntries,
): string {
const filePath = resolvePath(
outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`,
process.cwd(),
);
const dir = dirname(filePath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(filePath, serializeSessionBranch(sessionManager, createTrailingEntries));
return filePath;
}

View file

@ -25,6 +25,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
{ name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
{ name: "import", description: "Import and resume a session from a JSONL file" },
{ name: "share", description: "Share session as a secret GitHub gist" },
{ name: "bug", description: "Report a bug to the Pi developers", argumentHint: "<description>" },
{ name: "copy", description: "Copy last agent message to clipboard" },
{ name: "name", description: "Set session display name" },
{ name: "session", description: "Show session info and stats" },

View file

@ -1,11 +1,12 @@
import { readFile } from "node:fs/promises";
import { DEFAULT_RADIUS_GATEWAY, normalizeRadiusGatewayUrl } from "@earendil-works/pi-ai/providers/radius-config";
import { normalizeRadiusGatewayUrl } from "@earendil-works/pi-ai/providers/radius-config";
import { getAuthCredential } from "../cli/auth-command.ts";
import type { AuthInput } from "../cli/experimental/command-options.ts";
import { ModelRuntime } from "../core/model-runtime.ts";
import { getRadiusGatewayUrl } from "../core/radius.ts";
import { resolvePath } from "../utils/paths.ts";
export const ENV_RADIUS_GATEWAY = "PI_RADIUS_GATEWAY";
export { ENV_RADIUS_GATEWAY } from "../core/radius.ts";
export interface RadiusRelayAuth {
readonly gateway: string;
@ -18,7 +19,7 @@ export class RadiusRelayAuthResolver {
readonly #gateway: string;
#modelRuntime: Promise<ModelRuntime> | undefined;
constructor(input?: AuthInput, gateway = process.env[ENV_RADIUS_GATEWAY] ?? DEFAULT_RADIUS_GATEWAY) {
constructor(input?: AuthInput, gateway = getRadiusGatewayUrl()) {
this.#input = input;
this.#gateway = normalizeRadiusGatewayUrl(gateway);
}

View file

@ -0,0 +1,292 @@
import * as path from "node:path";
import type { Container, EditorComponent, TUI } from "@earendil-works/pi-tui";
import { getAuthCredential } from "../../cli/auth-command.ts";
import type { AgentSession } from "../../core/agent-session.ts";
import {
BUG_REPORT_CUSTOM_ENTRY_TYPE,
type BugReportBundle,
type BugReportSessionEntryData,
bugReportArchiveFileName,
collectBugReportDiagnostics,
collectBugReportMetadata,
writeBugReportArchive,
} from "../../core/bug-report.ts";
import { uploadBugReport } from "../../core/bug-report-upload.ts";
import { clearCrashLog, readCrashLog } from "../../core/crash-log.ts";
import { getRadiusGatewayUrl, RADIUS_PROVIDER_ID } from "../../core/radius.ts";
import { serializeSessionBranch } from "../../core/session-export.ts";
import { BorderedLoader } from "./components/bordered-loader.ts";
import { ExtensionInputComponent } from "./components/extension-input.ts";
import { ExtensionSelectorComponent } from "./components/extension-selector.ts";
import { createShareTrailingEntries } from "./session-share.ts";
import { theme } from "./theme/theme.ts";
interface BugReportContext {
session: AgentSession;
ui: TUI;
editorContainer: Container;
editor: EditorComponent;
showStatus: (message: string) => void;
showError: (message: string) => void;
}
interface BugReportOptions {
hint?: string;
includeSession: boolean;
includeSummary: boolean;
delivery: "upload" | "zip";
}
type Overlay = Container & { dispose?: () => void };
const DISCLAIMER =
"This report goes to the Pi developers (Earendil) and is not shared publicly. It includes your pi version, operating system, the current model and provider configuration (without API keys), loaded extensions, settings, and provider error diagnostics from this session.";
const TRANSCRIPT_NOTE =
"The transcript contains your messages, model output, tool calls and their results, including file contents and command output read during this session.";
/** Run the `/bug` flow: consent, optional summary, then upload or export. */
export async function reportBug(context: BugReportContext, initialHint?: string): Promise<void> {
const options = await promptForOptions(context, initialHint);
if (!options) {
context.showStatus("Bug report cancelled");
return;
}
let summary: string | undefined;
if (options.includeSummary) {
const loader = showLoader(
context,
`Writing summary with ${context.session.model?.name ?? "the current model"}...`,
);
try {
summary = await context.session.summarizeForBugReport({ hint: options.hint, signal: loader.signal });
} catch (error: unknown) {
restoreEditor(context, loader);
if (loader.signal.aborted) context.showStatus("Bug report cancelled");
else context.showError(`Failed to write bug report summary: ${errorMessage(error)}`);
return;
}
restoreEditor(context, loader);
if (loader.signal.aborted) {
context.showStatus("Bug report cancelled");
return;
}
}
let bundle: BugReportBundle;
try {
bundle = buildBundle(context.session, options, summary);
} catch (error: unknown) {
context.showError(`Failed to build bug report: ${errorMessage(error)}`);
return;
}
if (options.delivery === "upload") {
const failure = await upload(context, bundle);
if (failure === undefined) return;
const fallback = await choose(
context,
"Upload failed",
["Export as Zip", "Cancel"],
`${failure}\n\nExport the report as a zip archive instead?`,
);
if (fallback !== "Export as Zip") {
context.showStatus("Bug report cancelled");
return;
}
}
await exportZip(context, bundle);
}
async function promptForOptions(
context: BugReportContext,
initialHint: string | undefined,
): Promise<BugReportOptions | undefined> {
const hint = await input(context, "Report a bug", `${DISCLAIMER}\n\nWhat went wrong? (optional)`, initialHint);
if (hint === null) return undefined;
const transcript = await choose(
context,
"Include the session transcript?",
["Yes, include the transcript", "No"],
TRANSCRIPT_NOTE,
);
if (!transcript) return undefined;
const includeSession = transcript !== "No";
let includeSummary = false;
if (!includeSession) {
const model = context.session.model;
const summary = await choose(
context,
`Attach a summary written by ${model?.name ?? "the current model"} instead?`,
["Yes, generate a summary", "No"],
`The transcript is sent to ${model?.provider ?? "your provider"} with your credentials and tokens. Only the generated summary is attached; the transcript stays on your machine.`,
);
if (!summary) return undefined;
includeSummary = summary !== "No";
}
const description = hint.trim();
const delivery = await choose(
context,
"Bug report",
["Upload Report", "Export as Zip", "Cancel"],
`Description: ${description || "none"}\nTranscript: ${includeSession ? "included" : "not included"}\nSummary: ${includeSummary ? `written by ${context.session.model?.name ?? "the current model"}` : "none"}\n\nUpload sends the report to ${new URL(getRadiusGatewayUrl()).host}. Export writes a zip archive to the current directory instead.`,
);
if (!delivery || delivery === "Cancel") return undefined;
return {
hint: description || undefined,
includeSession,
includeSummary,
delivery: delivery === "Upload Report" ? "upload" : "zip",
};
}
function buildBundle(session: AgentSession, options: BugReportOptions, summary: string | undefined): BugReportBundle {
const extensions = session.resourceLoader.getExtensions();
return {
metadata: collectBugReportMetadata({
hint: options.hint,
sessionId: session.sessionId,
cwd: session.sessionManager.getCwd(),
includeSession: options.includeSession,
includeSummary: summary !== undefined,
messageCount: session.messages.length,
model: session.model,
modelRuntime: session.modelRuntime,
thinkingLevel: session.thinkingLevel,
extensions: extensions.extensions,
extensionErrors: extensions.errors,
globalSettings: session.settingsManager.getGlobalSettings(),
projectSettings: session.settingsManager.getProjectSettings(),
}),
diagnostics: collectBugReportDiagnostics(session.sessionManager, readCrashLog()),
summary,
sessionJsonl: options.includeSession
? serializeSessionBranch(session.sessionManager, (parentId, timestamp) =>
createShareTrailingEntries(session, parentId, timestamp),
)
: undefined,
};
}
async function upload(context: BugReportContext, bundle: BugReportBundle): Promise<string | undefined> {
const loader = showLoader(context, "Uploading bug report...");
try {
const provider = context.session.modelRuntime.getProvider(RADIUS_PROVIDER_ID);
const token = provider
? getAuthCredential(
await context.session.modelRuntime.getAuth(RADIUS_PROVIDER_ID, { minOAuthValidityMs: 5 * 60_000 }),
)
: undefined;
const result = await uploadBugReport(bundle, { token, signal: loader.signal });
restoreEditor(context, loader);
recordInSession(context.session, bundle, { delivery: "upload" });
context.showStatus(`Bug report uploaded. Report ID: ${result.id}`);
return undefined;
} catch (error: unknown) {
restoreEditor(context, loader);
if (loader.signal.aborted) {
context.showStatus("Bug report cancelled");
return undefined;
}
return errorMessage(error);
}
}
async function exportZip(context: BugReportContext, bundle: BugReportBundle): Promise<void> {
const archivePath = path.join(process.cwd(), bugReportArchiveFileName(bundle.metadata.id));
try {
await writeBugReportArchive(bundle, archivePath);
} catch (error: unknown) {
context.showError(`Failed to write bug report: ${errorMessage(error)}`);
return;
}
recordInSession(context.session, bundle, { delivery: "zip", path: archivePath });
context.showStatus(`Bug report exported to: ${archivePath}\nReport ID: ${bundle.metadata.id}`);
}
function recordInSession(
session: AgentSession,
bundle: BugReportBundle,
delivery: Pick<BugReportSessionEntryData, "delivery" | "path">,
): void {
session.sessionManager.appendCustomEntry(BUG_REPORT_CUSTOM_ENTRY_TYPE, {
id: bundle.metadata.id,
createdAt: bundle.metadata.createdAt,
hint: bundle.metadata.hint,
sessionIncluded: bundle.metadata.session.included,
summaryIncluded: bundle.metadata.session.summaryIncluded,
...delivery,
} satisfies BugReportSessionEntryData);
if (bundle.diagnostics.crashes.length > 0) clearCrashLog();
}
function input(
context: BugReportContext,
title: string,
description: string,
initialValue?: string,
): Promise<string | null> {
return new Promise((resolve) => {
let component: ExtensionInputComponent;
const finish = (value: string | null) => {
restoreEditor(context, component);
resolve(value);
};
component = new ExtensionInputComponent(
title,
undefined,
(value) => finish(value),
() => finish(null),
{
initialValue,
description,
},
);
showOverlay(context, component);
});
}
function choose(
context: BugReportContext,
title: string,
options: string[],
description?: string,
): Promise<string | undefined> {
return new Promise((resolve) => {
let component: ExtensionSelectorComponent;
const finish = (value?: string) => {
restoreEditor(context, component);
resolve(value);
};
component = new ExtensionSelectorComponent(title, options, finish, () => finish(), {
tui: context.ui,
description,
});
showOverlay(context, component);
});
}
function showLoader(context: BugReportContext, message: string): BorderedLoader {
const loader = new BorderedLoader(context.ui, theme, message);
showOverlay(context, loader);
return loader;
}
function showOverlay(context: BugReportContext, component: Overlay): void {
context.editorContainer.clear();
context.editorContainer.addChild(component);
context.ui.setFocus(component);
context.ui.requestRender();
}
function restoreEditor(context: BugReportContext, component: Overlay): void {
component.dispose?.();
context.editorContainer.clear();
context.editorContainer.addChild(context.editor);
context.ui.setFocus(context.editor);
context.ui.requestRender();
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Unknown error";
}

View file

@ -11,6 +11,8 @@ import { keyHint } from "./keybinding-hints.ts";
export interface ExtensionInputOptions {
tui?: TUI;
timeout?: number;
initialValue?: string;
description?: string;
}
export class ExtensionInputComponent extends Container implements Focusable {
@ -49,6 +51,10 @@ export class ExtensionInputComponent extends Container implements Focusable {
this.titleText = new Text(theme.fg("accent", title), 1, 0);
this.addChild(this.titleText);
if (opts?.description) {
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("text", opts.description), 1, 0));
}
this.addChild(new Spacer(1));
if (opts?.timeout && opts.timeout > 0 && opts.tui) {
@ -61,6 +67,7 @@ export class ExtensionInputComponent extends Container implements Focusable {
}
this.input = new Input();
if (opts?.initialValue) this.input.setValue(opts.initialValue);
this.addChild(this.input);
this.addChild(new Spacer(1));
this.addChild(

View file

@ -13,6 +13,7 @@ export interface ExtensionSelectorOptions {
tui?: TUI;
timeout?: number;
onToggleToolsExpanded?: () => void;
description?: string;
}
export class ExtensionSelectorComponent extends Container {
@ -46,6 +47,10 @@ export class ExtensionSelectorComponent extends Container {
this.titleText = new Text(theme.fg("accent", theme.bold(title)), 1, 0);
this.addChild(this.titleText);
if (opts?.description) {
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("text", opts.description), 1, 0));
}
this.addChild(new Spacer(1));
if (opts?.timeout && opts.timeout > 0 && opts.tui) {

View file

@ -65,6 +65,7 @@ import {
computeCacheWaste,
detectCacheMiss,
} from "../../core/cache-stats.ts";
import { recordCrash, takeUnnotifiedCrash } from "../../core/crash-log.ts";
import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "../../core/defaults.ts";
import type {
AutocompleteProviderFactory,
@ -112,6 +113,7 @@ import { killTrackedDetachedChildren } from "../../utils/shell.ts";
import { loadAllHighlightLanguages } from "../../utils/syntax-highlight.ts";
import { ensureTool, type ToolStatus } from "../../utils/tools-manager.ts";
import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts";
import { reportBug } from "./bug-report.ts";
import { createChatViewport } from "./chat-viewport.ts";
import { ArminComponent } from "./components/armin.ts";
import { AssistantMessageComponent } from "./components/assistant-message.ts";
@ -473,6 +475,9 @@ export class InteractiveMode {
// Shutdown state
private shutdownRequested = false;
/** The `/bug` hint is shown at most once per session so error output stays readable. */
private bugReportHintShown = false;
// Extension UI state
private extensionSelector: ExtensionSelectorComponent | undefined = undefined;
private extensionInput: ExtensionInputComponent | undefined = undefined;
@ -1110,6 +1115,14 @@ export class InteractiveMode {
this.showWarning(modelFallbackMessage);
}
const crash = takeUnnotifiedCrash();
if (crash) {
const when = new Date(crash.timestamp).toLocaleString();
this.showWarning(
`${APP_NAME} crashed on ${when} (${crash.message}). Run /bug to report it; the crash details are attached automatically.`,
);
}
void this.maybeWarnAboutAnthropicSubscriptionAuth();
// Process initial messages
@ -1999,11 +2012,48 @@ export class InteractiveMode {
private async handleFatalRuntimeError(prefix: string, error: unknown): Promise<never> {
const message = error instanceof Error ? error.message : String(error);
this.showError(`${prefix}: ${message}`);
if (this.recordCrash("fatal_error", error)) {
this.chatContainer.addChild(new Text(theme.fg("muted", this.crashReportInstructions()), this.outputPad, 0));
}
stopThemeWatcher();
this.stop("transcript");
process.exit(1);
}
/** Persist a crash so the next start can point the user at `/bug`. Returns false when nothing was written. */
private recordCrash(kind: "uncaught_exception" | "fatal_error", error: unknown): boolean {
try {
return (
recordCrash({
kind,
error,
sessionFile: this.session.sessionFile,
cwd: this.session.sessionManager.getCwd(),
}) !== undefined
);
} catch {
return false;
}
}
private crashReportInstructions(): string {
const resume = this.session.sessionFile ? `run \`${APP_NAME} -r\` to resume the session, then` : "start pi and";
return `To report this crash: ${resume} run /bug. The crash details are attached automatically.`;
}
private suggestBugReport(): void {
if (this.bugReportHintShown) return;
this.bugReportHintShown = true;
this.chatContainer.addChild(
new Text(
theme.fg("muted", `If this looks like a ${APP_NAME} bug, /bug sends a report to the developers.`),
this.outputPad,
0,
),
);
this.ui.requestRender();
}
private renderCurrentSessionState(): void {
this.loadedResourcesContainer.clear();
this.chatContainer.clear();
@ -3009,6 +3059,12 @@ export class InteractiveMode {
this.editor.setText("");
return;
}
if (text === "/bug" || text.startsWith("/bug ")) {
const hint = text.slice("/bug".length).trim();
this.editor.setText("");
await this.handleBugCommand(hint ? hint : undefined);
return;
}
if (text === "/copy") {
await this.handleCopyCommand();
this.editor.setText("");
@ -3307,6 +3363,7 @@ export class InteractiveMode {
});
}
this.pendingTools.clear();
if (this.streamingMessage.stopReason === "error") this.suggestBugReport();
} else {
// Args are now complete - trigger diff computation for edit tools
for (const [, component] of this.pendingTools.entries()) {
@ -3476,6 +3533,7 @@ export class InteractiveMode {
// Show error only on final failure (success shows normal response)
if (!event.success) {
this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
this.suggestBugReport();
}
this.ui.requestRender();
break;
@ -4049,6 +4107,9 @@ export class InteractiveMode {
} catch {}
console.error(`${APP_NAME} exiting due to uncaughtException:`);
console.error(error);
if (this.recordCrash("uncaught_exception", error)) {
console.error(`\n${this.crashReportInstructions()}`);
}
process.exit(1);
}
@ -6181,6 +6242,20 @@ export class InteractiveMode {
});
}
private async handleBugCommand(hint: string | undefined): Promise<void> {
await reportBug(
{
session: this.session,
ui: this.ui,
editorContainer: this.editorContainer,
editor: this.editor,
showStatus: (message) => this.showStatus(message),
showError: (message) => this.showError(message),
},
hint,
);
}
private async handleCopyCommand(
options: { flashConfirmation?: boolean; preferSelection?: boolean } = {},
): Promise<void> {

View file

@ -21,9 +21,13 @@ interface SessionShareContext {
showError: (message: string) => void;
}
/** Export the current branch with presentation metadata for Radius. */
export function exportSessionForShare(filePath: string, session: AgentSession): void {
exportSessionToJsonl(session.sessionManager, filePath, (parentId, timestamp) => [
/** Trailing `pi.share` entry carrying the system prompt and tool schemas for the session viewer. */
export function createShareTrailingEntries(
session: AgentSession,
parentId: string | null,
timestamp: string,
): object[] {
return [
{
type: "custom",
customType: "pi.share",
@ -39,7 +43,14 @@ export function exportSessionForShare(filePath: string, session: AgentSession):
})),
},
},
]);
];
}
/** Export the current branch with presentation metadata for Radius. */
export function exportSessionForShare(filePath: string, session: AgentSession): void {
exportSessionToJsonl(session.sessionManager, filePath, (parentId, timestamp) =>
createShareTrailingEntries(session, parentId, timestamp),
);
}
/** Share the current session through Radius, falling back to a private gist. */

View file

@ -0,0 +1,72 @@
import { writeFile } from "node:fs/promises";
import { crc32, deflateRawSync } from "node:zlib";
interface ZipEntry {
name: string;
data: string | Uint8Array;
}
function dosDateTime(date: Date): { time: number; day: number } {
return {
time: (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1),
day: ((Math.max(1980, date.getFullYear()) - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
};
}
/** Create the small, classic ZIP archives used by bug reports. */
function createZipArchive(entries: readonly ZipEntry[]): Buffer {
const files: Buffer[] = [];
const directory: Buffer[] = [];
const { time, day } = dosDateTime(new Date());
let offset = 0;
for (const entry of entries) {
const name = Buffer.from(entry.name);
const data = typeof entry.data === "string" ? Buffer.from(entry.data) : Buffer.from(entry.data);
const compressed = deflateRawSync(data);
const checksum = crc32(data);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(8, 8);
local.writeUInt16LE(time, 10);
local.writeUInt16LE(day, 12);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(compressed.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(name.length, 26);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(8, 10);
central.writeUInt16LE(time, 12);
central.writeUInt16LE(day, 14);
central.writeUInt32LE(checksum, 16);
central.writeUInt32LE(compressed.length, 20);
central.writeUInt32LE(data.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt32LE(offset, 42);
files.push(local, name, compressed);
directory.push(central, name);
offset += local.length + name.length + compressed.length;
}
const centralDirectory = Buffer.concat(directory);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralDirectory.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...files, centralDirectory, end]);
}
export function writeZipArchive(filePath: string, entries: readonly ZipEntry[]): Promise<void> {
return writeFile(filePath, createZipArchive(entries));
}

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { redactJsonValue, redactUrl } from "../src/core/bug-report.ts";
describe("bug report redaction", () => {
it("removes URL credentials and secret query parameters", () => {
expect(redactUrl("https://user:pass@proxy.example.com:8080/")).toBe("https://proxy.example.com:8080/");
expect(redactUrl("git:https://pat@github.com/org/repo")).toBe("git:https://github.com/org/repo");
expect(redactUrl("https://api.example/v1?api-key=abc&model=x")).toBe(
"https://api.example/v1?api-key=%3Credacted%3E&model=x",
);
});
it("redacts nested secret values without hiding token counts", () => {
expect(
redactJsonValue({
apiKey: "sk-123",
headers: { Authorization: "Bearer x", "X-Trace": "1" },
compaction: { reserveTokens: 16_384, keepRecentTokens: 20_000 },
baseUrl: "https://me:secret@example.com/",
}),
).toEqual({
apiKey: "<redacted>",
headers: { Authorization: "<redacted>", "X-Trace": "1" },
compaction: { reserveTokens: 16_384, keepRecentTokens: 20_000 },
baseUrl: "https://example.com/",
});
});
});