Merge remote-tracking branch 'origin/main' into dev

This commit is contained in:
David Brailovsky 2026-08-20 09:20:25 +02:00
commit d94e521baa
81 changed files with 2521 additions and 1193 deletions

View file

@ -274,29 +274,6 @@ async function runLoop(
await emit({ type: "agent_end", messages: newMessages });
}
/** Build the provider context using the same transform and conversion pipeline as an agent request. */
export async function buildProviderContext(
context: AgentContext,
config: Pick<AgentLoopConfig, "convertToLlm" | "transformContext">,
signal?: AbortSignal,
): Promise<Context> {
// Apply context transform if configured (AgentMessage[] → AgentMessage[])
let messages = context.messages;
if (config.transformContext) {
messages = await config.transformContext(messages, signal);
}
// Convert to LLM-compatible messages (AgentMessage[] → Message[])
const llmMessages = await config.convertToLlm(messages);
// Build LLM context
return {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
}
/**
* Stream an assistant response from the LLM.
* This is where AgentMessage[] gets transformed to Message[] for the LLM.
@ -308,7 +285,21 @@ async function streamAssistantResponse(
emit: AgentEventSink,
streamFunction: StreamFn,
): Promise<AssistantMessage> {
const llmContext = await buildProviderContext(context, config, signal);
// Apply context transform if configured (AgentMessage[] → AgentMessage[])
let messages = context.messages;
if (config.transformContext) {
messages = await config.transformContext(messages, signal);
}
// Convert to LLM-compatible messages (AgentMessage[] → Message[])
const llmMessages = await config.convertToLlm(messages);
// Build LLM context
const llmContext: Context = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
// Resolve API key (important for expiring tokens)
const resolvedApiKey =

View file

@ -1,5 +1,4 @@
import type {
Context,
ImageContent,
Message,
Model,
@ -8,11 +7,7 @@ import type {
ThinkingBudgets,
Transport,
} from "@earendil-works/pi-ai";
import {
buildProviderContext as buildProviderContextFromAgentContext,
runAgentLoop,
runAgentLoopContinue,
} from "./agent-loop.ts";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
import { getDefaultStreamFn } from "./stream-fn.ts";
import type {
AfterToolCallContext,
@ -266,15 +261,6 @@ export class Agent {
return this._state;
}
/** Build a provider context through the same transform and conversion pipeline used by agent requests. */
async buildProviderContext(context: AgentContext, signal?: AbortSignal): Promise<Context> {
return buildProviderContextFromAgentContext(
context,
{ convertToLlm: this.convertToLlm, transformContext: this.transformContext },
signal,
);
}
/** Controls how queued steering messages are drained. */
set steeringMode(mode: QueueMode) {
this.steeringQueue.mode = mode;

View file

@ -1,16 +1,9 @@
import {
type AssistantMessage,
type AssistantMessageEvent,
EventStream,
getModel,
type Message,
} from "@earendil-works/pi-ai/compat";
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import {
Agent,
type AgentEvent,
type AgentMessage,
type AgentTool,
type AgentToolUpdateCallback,
type StreamFn,
@ -141,54 +134,6 @@ describe("Agent", () => {
expect(agent.state.thinkingLevel).toBe("low");
});
it("builds provider context through configured transforms", async () => {
const sourceMessages: AgentMessage[] = [
{ role: "user", content: "discard", timestamp: 1 },
{ role: "user", content: "keep", timestamp: 2 },
];
const transformedMessages: AgentMessage[] = [sourceMessages[1]];
const callOrder: string[] = [];
const abortController = new AbortController();
let transformInput: AgentMessage[] | undefined;
let convertInput: AgentMessage[] | undefined;
const agent = new Agent({
streamFn: unusedStreamFunction,
transformContext: async (messages, signal) => {
callOrder.push("transform");
transformInput = messages;
expect(signal).toBe(abortController.signal);
return transformedMessages;
},
convertToLlm: async (messages) => {
callOrder.push("convert");
convertInput = messages;
return messages.filter(
(message): message is Message =>
message.role === "user" || message.role === "assistant" || message.role === "toolResult",
);
},
});
const tools: AgentTool[] = [];
const context = await agent.buildProviderContext(
{
systemPrompt: "System prompt",
messages: sourceMessages,
tools,
},
abortController.signal,
);
expect(callOrder).toEqual(["transform", "convert"]);
expect(transformInput).toBe(sourceMessages);
expect(convertInput).toBe(transformedMessages);
expect(context).toEqual({
systemPrompt: "System prompt",
messages: transformedMessages,
tools,
});
});
it("should subscribe to events", () => {
const agent = new Agent({ streamFn: unusedStreamFunction });

View file

@ -13,6 +13,7 @@
### Fixed
- Fixed OpenAI-compatible Chat Completions reasoning replay to preserve and resend assistant-level `reasoning_details` (`reasoning.text`, `reasoning.summary`, and `reasoning.encrypted`) verbatim and in order ([#7994](https://github.com/earendil-works/pi/issues/7994)).
- Fixed Anthropic server-side fallback responses being priced with the requested model instead of the returned fallback model ([#8285](https://github.com/earendil-works/pi/issues/8285)).
- Fixed Azure OpenAI Responses ignoring `toolChoice` in provider-specific stream requests.
- Added `deepseek-v4-pro-0813` to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)).

View file

@ -235,13 +235,6 @@ const NVIDIA_NIM_UNSUPPORTED_MODELS = new Set([
"upstage/solar-10.7b-instruct",
]);
const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]);
const ZAI_GLM52_THINKING_LEVEL_MAP = {
minimal: null,
low: "high",
medium: "high",
high: "high",
max: "max",
} as const;
const OPENCODE_GO_GLM52_THINKING_LEVEL_MAP = {
off: null,
minimal: null,
@ -752,18 +745,20 @@ function isAnthropicFallbackMetadataModel(model: Model<Api>): model is Model<"an
);
}
function applyAnthropicFallbackCostMetadata(models: readonly Model<"anthropic-messages">[]): void {
function applyAnthropicAllowedFallbackModelMetadata(models: readonly Model<"anthropic-messages">[]): void {
const modelsById = new Map(models.map((model) => [model.id, model]));
for (const [modelId, fallbackModelIds] of Object.entries(ANTHROPIC_ALLOWED_FALLBACK_MODELS)) {
const model = modelsById.get(modelId);
if (!model?.compat?.allowedFallbackModels) continue;
if (!model) continue;
for (const fallbackModelId of fallbackModelIds) {
const fallback = model.compat.allowedFallbackModels.find((target) => target.model === fallbackModelId);
const allowedFallbackModels = fallbackModelIds.flatMap((fallbackModelId) => {
const fallbackModel = modelsById.get(fallbackModelId);
if (fallback && fallbackModel) {
fallback.cost = fallbackModel.cost;
}
return fallbackModel
? [{ provider: fallbackModel.provider, model: fallbackModel.id, cost: fallbackModel.cost }]
: [];
});
if (allowedFallbackModels.length > 0) {
mergeAnthropicMessagesCompat(model, { allowedFallbackModels });
}
}
}
@ -985,12 +980,6 @@ function getAnthropicMessagesCompat(provider: string, modelId: string): Anthropi
if (EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`)) {
compat.supportsEagerToolInputStreaming = false;
}
if (provider === "anthropic") {
const allowedFallbackModels = ANTHROPIC_ALLOWED_FALLBACK_MODELS[modelId];
if (allowedFallbackModels) {
compat.allowedFallbackModels = allowedFallbackModels.map((fallbackModel) => ({ model: fallbackModel }));
}
}
if (provider === "xiaomi" || provider.startsWith("xiaomi-token-plan-")) {
compat.allowEmptySignature = true;
}
@ -1179,6 +1168,66 @@ async function fetchAiGatewayModels(): Promise<Model<any>[]> {
}
}
function processZaiModels(data: ModelsDevCatalog): Model<Api>[] {
const variants = [
{
source: "zai-coding-plan",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
},
{
source: "zhipuai-coding-plan",
provider: "zai-coding-cn",
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
},
] as const;
const models: Model<Api>[] = [];
for (const { source, provider, baseUrl } of variants) {
for (const [modelId, model] of Object.entries(data[source]?.models ?? {})) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
const supportsImage = m.modalities?.input?.includes("image");
const thinkingLevelMap = getEffortThinkingLevelMap(m.reasoning_options ?? []);
const isGlm52 = modelId === "glm-5.2" || modelId === "glm-5.2-highspeed";
if (thinkingLevelMap && isGlm52) {
thinkingLevelMap.off = "none";
}
const supportsReasoningEffort = thinkingLevelMap !== undefined;
const referenceCost = data.zai?.models[modelId]?.cost ?? m.cost;
models.push({
id: modelId,
name: m.name || modelId,
api: "openai-completions",
provider,
baseUrl,
reasoning: m.reasoning === true,
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
input: supportsImage ? ["text", "image"] : ["text"],
cost: {
input: referenceCost?.input || 0,
output: referenceCost?.output || 0,
cacheRead: referenceCost?.cache_read || 0,
cacheWrite: referenceCost?.cache_write || 0,
},
compat: {
supportsDeveloperRole: false,
thinkingFormat: "zai",
...(supportsReasoningEffort ? { supportsReasoningEffort: true } : {}),
...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}),
},
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
});
recordModelsDevReasoningOptions(provider, modelId, m);
}
}
return models;
}
function processBasetenModels(provider: ModelsDevProvider | undefined): Model<Api>[] {
if (!provider?.models) return [];
@ -1701,56 +1750,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}
}
// Process zAi models
const zaiCodingPlanVariants = [
{
source: "zai-coding-plan",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
},
{
source: "zhipuai-coding-plan",
provider: "zai-coding-cn",
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
},
] as const;
for (const { source, provider, baseUrl } of zaiCodingPlanVariants) {
for (const [modelId, model] of Object.entries(data[source]?.models ?? {})) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
const supportsImage = m.modalities?.input?.includes("image");
const isGlm52 = modelId === "glm-5.2";
const referenceCost = data.zai?.models[modelId]?.cost ?? m.cost;
models.push({
id: modelId,
name: m.name || modelId,
api: "openai-completions",
provider,
baseUrl,
reasoning: m.reasoning === true,
...(isGlm52 ? { thinkingLevelMap: ZAI_GLM52_THINKING_LEVEL_MAP } : {}),
input: supportsImage ? ["text", "image"] : ["text"],
cost: {
input: referenceCost?.input || 0,
output: referenceCost?.output || 0,
cacheRead: referenceCost?.cache_read || 0,
cacheWrite: referenceCost?.cache_write || 0,
},
compat: {
supportsDeveloperRole: false,
thinkingFormat: "zai",
...(isGlm52 ? { supportsReasoningEffort: true } : {}),
...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}),
},
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
});
recordModelsDevReasoningOptions(provider, modelId, m);
}
}
models.push(...processZaiModels(data));
// Process Mistral models
if (data.mistral?.models) {
@ -2809,7 +2809,7 @@ async function generateModels() {
applyOpenAIToolSearchMetadata(model);
applyOpenAIExplicitPromptCacheMetadata(model);
}
applyAnthropicFallbackCostMetadata(allModels.filter(isAnthropicFallbackMetadataModel));
applyAnthropicAllowedFallbackModelMetadata(allModels.filter(isAnthropicFallbackMetadataModel));
// Group by provider and deduplicate by model ID
const providers: Record<string, Record<string, Model<any>>> = {};

View file

@ -10,7 +10,6 @@ import type {
import { calculateCost } from "../models.ts";
import type {
AnthropicMessagesCompat,
AnthropicRefusalFallback,
Api,
AssistantMessage,
CacheRetention,
@ -170,13 +169,17 @@ export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max";
export type AnthropicThinkingDisplay = "summarized" | "omitted";
type MessageCreateParamsStreamingWithFallbacks = MessageCreateParamsStreaming & {
fallbacks?: AnthropicRefusalFallback;
fallbacks?: readonly { model: string }[];
};
const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14";
const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
const SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01";
function shouldUseServerSideFallbackBeta(model: Model<"anthropic-messages">): boolean {
return (model.compat?.allowedFallbackModels?.length ?? 0) > 0;
}
function getAnthropicCompat(
model: Model<"anthropic-messages">,
): Required<Omit<AnthropicMessagesCompat, "forceAdaptiveThinking" | "allowedFallbackModels">> {
@ -254,12 +257,6 @@ export interface AnthropicOptions extends StreamOptions {
* Default: true.
*/
interleavedThinking?: boolean;
/**
* Anthropic refusal fallback. When set, the request includes the server-side
* fallback beta and Anthropic retries eligible refusals on the configured
* fallback target before returning a final response.
*/
refusalFallbacks?: AnthropicRefusalFallback;
/**
* Anthropic tool choice behavior. String values map to Anthropic's built-in
* choices; `{ type: "tool", name }` forces a specific tool.
@ -284,18 +281,8 @@ function mergeHeaders(...headerSources: (ProviderHeaders | undefined)[]): Provid
return merged;
}
function mergeClientHeaders(
model: Model<"anthropic-messages">,
...headerSources: (ProviderHeaders | undefined)[]
): ProviderHeaders {
const merged = mergeHeaders(...headerSources);
if (model.provider === "kimi-coding") {
for (const name of Object.keys(merged)) {
if (name.toLowerCase() === "user-agent") delete merged[name];
}
merged["User-Agent"] = getPiUserAgent();
}
return merged;
function mergeClientHeaders(...headerSources: (ProviderHeaders | undefined)[]): ProviderHeaders {
return mergeHeaders({ "User-Agent": getPiUserAgent() }, ...headerSources);
}
function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean {
@ -566,7 +553,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
apiKey,
options?.interleavedThinking ?? true,
shouldUseFineGrainedToolStreamingBeta(model, context),
options?.refusalFallbacks !== undefined,
shouldUseServerSideFallbackBeta(model),
options?.headers,
options?.fetch,
copilotDynamicHeaders,
@ -606,10 +593,9 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
const fallbackCost =
output.model === model.id
? undefined
: ((Array.isArray(options?.refusalFallbacks)
? options.refusalFallbacks.find((fallback) => fallback.model === output.model)?.cost
: undefined) ??
model.compat?.allowedFallbackModels?.find((fallback) => fallback.model === output.model)?.cost);
: model.compat?.allowedFallbackModels?.find(
(fallback) => fallback.provider === model.provider && fallback.model === output.model,
)?.cost;
usageModel = fallbackCost ? { ...model, id: output.model, cost: fallbackCost } : model;
// Capture initial token usage from message_start event
// This ensures we have input token counts even if the stream is aborted early
@ -850,7 +836,6 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti
if (!options?.reasoning) {
return stream(model, context, {
...base,
refusalFallbacks: options?.refusalFallbacks,
thinkingEnabled: false,
} satisfies AnthropicOptions);
}
@ -861,7 +846,6 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti
const effort = mapThinkingLevelToEffort(model, options.reasoning);
return stream(model, context, {
...base,
refusalFallbacks: options?.refusalFallbacks,
thinkingEnabled: true,
effort,
} satisfies AnthropicOptions);
@ -880,7 +864,6 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti
return stream(model, context, {
...base,
refusalFallbacks: options?.refusalFallbacks,
maxTokens,
thinkingEnabled: true,
thinkingBudgetTokens: Math.min(adjusted.thinkingBudget, Math.max(0, maxTokens - 1024)),
@ -924,7 +907,6 @@ function createClient(
dangerouslyAllowBrowser: true,
fetch,
defaultHeaders: mergeClientHeaders(
model,
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
@ -948,7 +930,6 @@ function createClient(
dangerouslyAllowBrowser: true,
fetch,
defaultHeaders: mergeClientHeaders(
model,
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
@ -968,7 +949,6 @@ function createClient(
const sessionAffinityHeaders: ProviderHeaders =
sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders ? { "x-session-affinity": sessionId } : {};
const defaultHeaders = mergeClientHeaders(
model,
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
@ -1124,11 +1104,9 @@ function buildParams(
}
}
if (options?.refusalFallbacks !== undefined) {
params.fallbacks =
options.refusalFallbacks === "default"
? "default"
: options.refusalFallbacks.map((fallback) => ({ model: fallback.model }));
const allowedFallbackModels = model.compat?.allowedFallbackModels;
if (allowedFallbackModels && allowedFallbackModels.length > 0) {
params.fallbacks = allowedFallbackModels.map((fallback) => ({ model: fallback.model }));
}
return params;

View file

@ -13,6 +13,7 @@ import type {
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
@ -253,7 +254,7 @@ function resolveAzureConfig(
}
function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) {
const headers = { ...model.headers };
const headers = { "User-Agent": getPiUserAgent(), ...model.headers };
if (options?.headers) {
Object.assign(headers, options.headers);

View file

@ -101,10 +101,18 @@ export interface BedrockOptions extends StreamOptions {
bearerToken?: string;
}
type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string };
type Block = (TextContent | ThinkingContent | ToolCall) & {
index?: number;
partialJson?: string;
/** Scratch buffer for encrypted reasoning deltas, joined into `thinkingSignature`. */
redactedChunks?: Uint8Array[];
};
const EMPTY_TEXT_PLACEHOLDER = "<empty>";
/** Matches the placeholder the Anthropic API path uses for redacted thinking. */
const REDACTED_THINKING_PLACEHOLDER = "[Reasoning redacted]";
export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = (
model: Model<"bedrock-converse-stream">,
context: Context,
@ -313,13 +321,13 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> =
throw new Error(output.errorMessage || "An unknown error occurred");
}
// A stream can settle without stopping every block, so finalize here too.
for (const block of output.content) finalizeStreamingBlock(block as Block);
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
for (const block of output.content) {
delete (block as Block).index;
// partialJson is only a streaming scratch buffer; never persist it.
delete (block as Block).partialJson;
finalizeStreamingBlock(block as Block);
}
output.stopReason = options.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatBedrockError(error);
@ -624,14 +632,56 @@ function handleContentBlockDelta(
partial: output,
});
}
if (delta.reasoningContent.signature) {
// `thinkingSignature` holds either an Anthropic signature or an opaque redacted
// payload, never both: mixing them would corrupt whichever arrived first.
if (delta.reasoningContent.signature && !thinkingBlock.redacted) {
thinkingBlock.thinkingSignature =
(thinkingBlock.thinkingSignature || "") + delta.reasoningContent.signature;
}
if (delta.reasoningContent.redactedContent?.length) {
// Encrypted reasoning from non-Anthropic models on Bedrock (e.g. OpenAI GPT-5.6).
// The payload is opaque, so keep it verbatim in `thinkingSignature` the way the
// Anthropic path stores redacted thinking, and replay it on the next turn.
if (!thinkingBlock.redacted) {
thinkingBlock.redacted = true;
thinkingBlock.thinkingSignature = "";
thinkingBlock.thinking += REDACTED_THINKING_PLACEHOLDER;
stream.push({
type: "thinking_delta",
contentIndex: thinkingIndex,
delta: REDACTED_THINKING_PLACEHOLDER,
partial: output,
});
}
thinkingBlock.redactedChunks ??= [];
thinkingBlock.redactedChunks.push(delta.reasoningContent.redactedContent);
}
}
}
}
/**
* Encodes buffered encrypted reasoning into `thinkingSignature` and drops the scratch
* buffer, which must never reach a persisted message: `Uint8Array` serializes to an
* index-keyed object roughly ten times the size of the base64 payload.
*/
function flushRedactedContent(block: Block): void {
if (block.type !== "thinking" || !block.redactedChunks) return;
block.thinkingSignature = bytesToBase64(block.redactedChunks);
delete block.redactedChunks;
}
/**
* Strips every streaming scratch field. Runs from the terminal paths as well as
* `contentBlockStop`, because a stream can settle without stopping each block.
*/
function finalizeStreamingBlock(block: Block): void {
delete block.index;
// partialJson is only a streaming scratch buffer; never persist it.
delete block.partialJson;
flushRedactedContent(block);
}
function handleMetadata(
event: ConverseStreamMetadataEvent,
model: Model<"bedrock-converse-stream">,
@ -663,6 +713,7 @@ function handleContentBlockStop(
stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output });
break;
case "thinking":
flushRedactedContent(block);
stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output });
break;
case "toolCall":
@ -936,6 +987,15 @@ function convertMessages(
});
break;
case "thinking": {
// Encrypted reasoning is opaque: replay the stored payload as the
// `redactedContent` member instead of lowering it to reasoning text.
if (c.redacted) {
const redactedContent = decodeRedactedContent(c.thinkingSignature);
if (redactedContent?.length) {
contentBlocks.push({ reasoningContent: { redactedContent } });
}
continue;
}
// Skip empty thinking blocks
const thinking = sanitizeSurrogates(c.thinking);
if (thinking.trim().length === 0) continue;
@ -1223,11 +1283,43 @@ function createImageBlock(mimeType: string, data: string) {
throw new Error(`Unknown image type: ${mimeType}`);
}
return { source: { bytes: base64ToBytes(data) }, format };
}
function base64ToBytes(data: string): Uint8Array {
const binaryString = atob(data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return { source: { bytes }, format };
return bytes;
}
/**
* Decodes a stored redacted payload. The AWS SDK hands the blob over as bytes, but a
* persisted session carries it as base64. A hand-edited or externally produced session
* can hold a signature that is not base64; drop that block instead of failing the
* whole request.
*/
function decodeRedactedContent(signature: string | undefined): Uint8Array | undefined {
if (!signature) return undefined;
try {
return base64ToBytes(signature);
} catch {
return undefined;
}
}
function bytesToBase64(chunks: Uint8Array[]): string {
// Encrypted reasoning runs to tens of KB, so build the binary string in slices
// rather than one concatenation per byte. The window stays under the engine's
// argument-count limit for spread calls.
const WINDOW = 0x8000;
let binary = "";
for (const chunk of chunks) {
for (let i = 0; i < chunk.length; i += WINDOW) {
binary += String.fromCharCode(...chunk.subarray(i, i + WINDOW));
}
}
return btoa(binary);
}

View file

@ -22,6 +22,7 @@ import type {
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { providerHeadersToRecord } from "../utils/headers.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts";
import {
@ -344,7 +345,7 @@ function createClient(
httpOptions.baseUrl = model.baseUrl;
httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append
}
const headers = providerHeadersToRecord({ ...model.headers, ...optionsHeaders });
const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders });
if (headers) {
httpOptions.headers = headers;
}

View file

@ -26,6 +26,7 @@ import type {
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { providerHeadersToRecord } from "../utils/headers.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts";
@ -391,7 +392,7 @@ function buildHttpOptions(model: Model<"google-vertex">, optionsHeaders?: Provid
}
}
const headers = providerHeadersToRecord({ ...model.headers, ...optionsHeaders });
const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders });
if (headers) {
httpOptions.headers = headers;
}

View file

@ -17,6 +17,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { shortHash } from "../utils/hash.ts";
import { headersToRecord } from "../utils/headers.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
import { buildBaseOptions } from "./simple-options.ts";
@ -329,6 +330,7 @@ class MistralHttpError extends Error {
function buildMistralHeaders(model: Model<"mistral-conversations">, apiKey: string, options?: MistralOptions): Headers {
const headers = new Headers({
"User-Agent": getPiUserAgent(),
accept: "text/event-stream",
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",

View file

@ -18,6 +18,7 @@ import type {
ChatTemplateKwargValue,
Context,
ImageContent,
JsonValue,
Message,
Model,
OpenAICompletionsCompat,
@ -40,7 +41,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { shortHash } from "../utils/hash.ts";
import { headersToRecord } from "../utils/headers.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { forcePiUserAgent } from "../utils/pi-user-agent.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
@ -128,18 +129,35 @@ function isImageContentBlock(block: { type: string }): block is ImageContent {
return block.type === "image";
}
function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedReasoningDetail {
if (typeof detail !== "object" || detail === null) {
function isReasoningDetailObject(detail: unknown): detail is Record<string, unknown> {
return typeof detail === "object" && detail !== null && !Array.isArray(detail);
}
function hasValidCommonReasoningDetailFields(candidate: Record<string, unknown>): boolean {
return (
(candidate.id === undefined || candidate.id === null || typeof candidate.id === "string") &&
(candidate.format === undefined || typeof candidate.format === "string") &&
(candidate.index === undefined || typeof candidate.index === "number")
);
}
function isOpenAIReasoningDetail(detail: unknown): detail is OpenAIReasoningDetail {
if (!isReasoningDetailObject(detail) || !hasValidCommonReasoningDetailFields(detail)) {
return false;
}
const candidate = detail as Record<string, unknown>;
return (
candidate.type === "reasoning.encrypted" &&
typeof candidate.id === "string" &&
candidate.id.length > 0 &&
typeof candidate.data === "string" &&
candidate.data.length > 0
);
switch (detail.type) {
case "reasoning.summary":
return typeof detail.summary === "string";
case "reasoning.encrypted":
return typeof detail.data === "string";
case "reasoning.text":
return (
typeof detail.text === "string" &&
(detail.signature === undefined || detail.signature === null || typeof detail.signature === "string")
);
default:
return false;
}
}
export interface OpenAICompletionsOptions extends StreamOptions {
@ -177,12 +195,71 @@ type KimiToolSystemMessageParam = {
tools: OpenAI.Chat.Completions.ChatCompletionTool[];
};
type OpenAIEncryptedReasoningDetail = {
type OpenAIReasoningDetailBase = Record<string, JsonValue> & {
id?: string | null;
format?: string;
index?: number;
};
type OpenAIReasoningSummaryDetail = OpenAIReasoningDetailBase & {
type: "reasoning.summary";
summary: string;
};
type OpenAIEncryptedReasoningDetail = OpenAIReasoningDetailBase & {
type: "reasoning.encrypted";
id: string;
data: string;
};
type OpenAIReasoningTextDetail = OpenAIReasoningDetailBase & {
type: "reasoning.text";
text: string;
signature?: string | null;
};
type OpenAIReasoningDetail = OpenAIReasoningSummaryDetail | OpenAIEncryptedReasoningDetail | OpenAIReasoningTextDetail;
function parseOpenAIReasoningDetails(signature: string | undefined): OpenAIReasoningDetail[] | undefined {
if (!signature) return undefined;
try {
const parsed = JSON.parse(signature) as unknown;
return Array.isArray(parsed) && parsed.length > 0 && parsed.every(isOpenAIReasoningDetail) ? parsed : undefined;
} catch {
return undefined;
}
}
function parseLegacyEncryptedReasoningDetail(
signature: string | undefined,
): OpenAIEncryptedReasoningDetail | undefined {
if (!signature) return undefined;
try {
const parsed = JSON.parse(signature) as unknown;
return isOpenAIReasoningDetail(parsed) &&
parsed.type === "reasoning.encrypted" &&
typeof parsed.id === "string" &&
parsed.id.length > 0 &&
parsed.data.length > 0
? parsed
: undefined;
} catch {
return undefined;
}
}
const OPENAI_COMPLETIONS_REASONING_FIELDS = ["reasoning", "reasoning_content", "reasoning_text"] as const;
type OpenAICompletionsReasoningField = (typeof OPENAI_COMPLETIONS_REASONING_FIELDS)[number];
function isOpenAICompletionsReasoningField(field: string): field is OpenAICompletionsReasoningField {
return OPENAI_COMPLETIONS_REASONING_FIELDS.includes(field as OpenAICompletionsReasoningField);
}
type ChatCompletionAssistantMessageParamWithReasoning = ChatCompletionAssistantMessageParam &
Partial<Record<OpenAICompletionsReasoningField, string>> & {
reasoning_details?: JsonValue[];
};
type ChatCompletionTextPartWithCacheControl = ChatCompletionContentPartText & {
cache_control?: OpenAICompatCacheControl;
};
@ -280,7 +357,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
let hasFinishReason = false;
const toolCallBlocksByIndex = new Map<number, StreamingToolCallBlock>();
const toolCallBlocksById = new Map<string, StreamingToolCallBlock>();
const pendingReasoningDetailsByToolCallId = new Map<string, string>();
const blocks = output.content as StreamingBlock[];
const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block);
const getCustomToolCallInput = (block: StreamingToolCallBlock): string => {
@ -371,16 +447,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
}
return thinkingBlock;
};
const applyPendingReasoningDetail = (block: StreamingToolCallBlock) => {
if (!block.id) {
return;
}
const pendingReasoningDetail = pendingReasoningDetailsByToolCallId.get(block.id);
if (pendingReasoningDetail) {
block.thoughtSignature = pendingReasoningDetail;
pendingReasoningDetailsByToolCallId.delete(block.id);
}
};
const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => {
const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined;
const name = toolCall.function?.name ?? toolCall.custom?.name ?? "";
@ -437,7 +503,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
};
delete block.partialArgs;
}
applyPendingReasoningDetail(block);
return block;
};
@ -555,15 +620,13 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
const reasoningDetails = (choice.delta as { reasoning_details?: unknown }).reasoning_details;
if (Array.isArray(reasoningDetails)) {
for (const detail of reasoningDetails) {
if (isEncryptedReasoningDetail(detail)) {
const serializedDetail = JSON.stringify(detail);
const matchingToolCall = toolCallBlocksById.get(detail.id);
if (matchingToolCall) {
matchingToolCall.thoughtSignature = serializedDetail;
} else {
pendingReasoningDetailsByToolCallId.set(detail.id, serializedDetail);
}
}
if (!isOpenAIReasoningDetail(detail)) continue;
const block = ensureThinkingBlock("");
const preservedDetails = parseOpenAIReasoningDetails(block.thinkingSignature) ?? [];
preservedDetails.push(detail);
// Keep provider replay data in the existing signature slot. OpenRouter
// requires the complete reasoning_details sequence in its original order.
block.thinkingSignature = JSON.stringify(preservedDetails);
}
}
}
@ -647,7 +710,7 @@ function createClient(
sessionId?: string,
compat: ResolvedOpenAICompletionsCompat = getCompat(model),
) {
const headers: ProviderHeaders = { ...model.headers };
const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers };
if (model.provider === "github-copilot") {
const hasImages = hasCopilotVisionInput(context.messages);
const copilotHeaders = buildCopilotDynamicHeaders({
@ -674,10 +737,6 @@ function createClient(
Object.assign(headers, optionsHeaders);
}
if (model.provider === "xai") {
forcePiUserAgent(headers);
}
return new OpenAI({
apiKey,
baseURL: model.baseUrl,
@ -1154,7 +1213,7 @@ export function convertMessages(
}
} else if (msg.role === "assistant") {
// Some providers don't accept null content, use empty string instead
const assistantMsg: ChatCompletionAssistantMessageParam = {
const assistantMsg: ChatCompletionAssistantMessageParamWithReasoning = {
role: "assistant",
content: compat.requiresAssistantAfterToolResult ? "" : null,
};
@ -1171,9 +1230,18 @@ export function convertMessages(
);
const assistantText = assistantTextParts.map((part) => part.text).join("");
const nonEmptyThinkingBlocks = msg.content
.filter(isThinkingContentBlock)
.filter((block) => block.thinking.trim().length > 0);
const thinkingBlocks = msg.content.filter(isThinkingContentBlock);
const toolCalls = msg.content.filter(isToolCallBlock);
const signedReasoningDetails = thinkingBlocks
.map((block) => parseOpenAIReasoningDetails(block.thinkingSignature))
.find((details) => details !== undefined);
const legacyReasoningDetails = toolCalls
.map((toolCall) => parseLegacyEncryptedReasoningDetail(toolCall.thoughtSignature))
.filter((detail): detail is OpenAIEncryptedReasoningDetail => detail !== undefined);
const preservedReasoningDetails =
signedReasoningDetails ?? (legacyReasoningDetails.length > 0 ? legacyReasoningDetails : undefined);
const nonEmptyThinkingBlocks = thinkingBlocks.filter((block) => block.thinking.trim().length > 0);
if (nonEmptyThinkingBlocks.length > 0) {
if (compat.requiresThinkingAsText) {
// Convert thinking blocks to plain text (no tags to avoid model mimicking them)
@ -1191,13 +1259,16 @@ export function convertMessages(
assistantMsg.content = assistantText;
}
// Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss)
let signature = nonEmptyThinkingBlocks[0].thinkingSignature;
if (model.provider === "opencode-go" && signature === "reasoning") {
signature = "reasoning_content";
}
if (signature && signature.length > 0) {
(assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n");
// reasoning_details is the structured alternative to a raw reasoning field.
if (!preservedReasoningDetails) {
// Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss)
let signature = nonEmptyThinkingBlocks[0].thinkingSignature;
if (model.provider === "opencode-go" && signature === "reasoning") {
signature = "reasoning_content";
}
if (signature && isOpenAICompletionsReasoningField(signature)) {
assistantMsg[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n");
}
}
}
} else if (assistantText.length > 0) {
@ -1209,7 +1280,6 @@ export function convertMessages(
assistantMsg.content = assistantText;
}
const toolCalls = msg.content.filter(isToolCallBlock);
if (toolCalls.length > 0) {
assistantMsg.tool_calls = toolCalls.map((tc): ChatCompletionMessageToolCall => {
const customInputProperty = options?.grammarToolInputProperties?.get(tc.name);
@ -1232,26 +1302,16 @@ export function convertMessages(
},
};
});
const reasoningDetails = toolCalls
.filter((tc) => tc.thoughtSignature)
.map((tc) => {
try {
return JSON.parse(tc.thoughtSignature!);
} catch {
return null;
}
})
.filter(Boolean);
if (reasoningDetails.length > 0) {
(assistantMsg as any).reasoning_details = reasoningDetails;
}
}
if (preservedReasoningDetails) {
assistantMsg.reasoning_details = preservedReasoningDetails;
}
if (
compat.requiresReasoningContentOnAssistantMessages &&
model.reasoning &&
(assistantMsg as { reasoning_content?: string }).reasoning_content === undefined
assistantMsg.reasoning_content === undefined
) {
(assistantMsg as { reasoning_content?: string }).reasoning_content = "";
assistantMsg.reasoning_content = "";
}
// Skip assistant messages that have no content and no tool calls.
// Some providers require "either content or tool_calls, but not none".

View file

@ -19,7 +19,7 @@ import { splitDeferredTools } from "../utils/deferred-tools.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { forcePiUserAgent } from "../utils/pi-user-agent.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
@ -224,7 +224,7 @@ function createClient(
sessionId?: string,
) {
const compat = getCompat(model);
const headers: ProviderHeaders = { ...model.headers };
const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers };
if (model.provider === "github-copilot") {
const hasImages = hasCopilotVisionInput(context.messages);
const copilotHeaders = buildCopilotDynamicHeaders({
@ -250,10 +250,6 @@ function createClient(
Object.assign(headers, optionsHeaders);
}
if (model.provider === "xai") {
forcePiUserAgent(headers);
}
return new OpenAI({
apiKey,
baseURL: model.baseUrl,

View file

@ -38,7 +38,6 @@ export * from "./session-resources.ts";
export * from "./types.ts";
export * from "./utils/assistant-message-frame.ts";
export * from "./utils/diagnostics.ts";
export { estimateContextTokens } from "./utils/estimate.ts";
export * from "./utils/event-stream.ts";
export * from "./utils/json-parse.ts";
export * from "./utils/overflow.ts";

View file

@ -304,21 +304,17 @@ export interface ImagesOptions extends ProviderRequestOptions<ImagesModel<Images
export type ProviderImagesOptions = ImagesOptions & Record<string, unknown>;
export interface AnthropicRefusalFallbackTarget {
export interface AnthropicAllowedFallbackModel {
provider: ProviderId;
model: string;
/** Local pricing for this fallback target. Stripped before sending the provider request. @internal */
cost?: ModelCost;
cost: ModelCost;
}
export type AnthropicRefusalFallback = "default" | readonly AnthropicRefusalFallbackTarget[];
// Unified options with reasoning passed to streamSimple() and completeSimple()
export interface SimpleStreamOptions extends StreamOptions {
/** Provider-neutral tool selection for simple requests. Default: "auto". */
toolChoice?: ToolChoice;
reasoning?: ThinkingLevel;
/** Anthropic server-side fallback for eligible refusal stop reasons. Anthropic providers only. */
refusalFallbacks?: AnthropicRefusalFallback;
/** Ask a capable provider to return a durable handle and continue the request asynchronously. */
deferred?: boolean | { window?: "15m" | "1h" | "24h" };
/** Custom token budgets for thinking levels (token-based providers only) */
@ -360,7 +356,7 @@ export interface TextContent {
export interface ThinkingContent {
type: "thinking";
thinking: string;
thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID
thinkingSignature?: string; // Provider-specific opaque or serialized reasoning replay data
/** When true, the thinking content was redacted by safety filters. The opaque
* encrypted payload is stored in `thinkingSignature` so it can be passed back
* to the API for multi-turn continuity. */
@ -697,11 +693,12 @@ export interface AnthropicMessagesCompat {
/** Whether the provider supports Anthropic strict tool schemas. Default: false; generated Anthropic models enable it explicitly. */
supportsStrictTools?: boolean;
/**
* Model ids Anthropic accepts in `fallbacks` for server-side refusal fallback.
* When absent or empty, callers must omit `fallbacks`; Anthropic rejects the
* field for models with no permitted fallback targets.
* Models Anthropic accepts in `fallbacks` for server-side refusal fallback,
* with local pricing metadata for returned fallback responses. When absent or
* empty, callers must omit `fallbacks`; Anthropic rejects the field for models
* with no permitted fallback targets.
*/
allowedFallbackModels?: AnthropicRefusalFallbackTarget[];
allowedFallbackModels?: AnthropicAllowedFallbackModel[];
/**
* Whether the provider supports deferred tools loaded by `tool_reference`
* blocks in tool results. Default: true for first-party Anthropic models

View file

@ -1,5 +1,4 @@
import type * as NodeOs from "node:os";
import type { ProviderHeaders } from "../types.ts";
type ProcessWithOsBuiltinModule = typeof process & {
getBuiltinModule?: (id: "node:os") => typeof NodeOs;
@ -18,10 +17,3 @@ const nodeOs = loadNodeOs();
export function getPiUserAgent(): string {
return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)";
}
export function forcePiUserAgent(headers: ProviderHeaders): void {
for (const name of Object.keys(headers)) {
if (name.toLowerCase() === "user-agent") delete headers[name];
}
headers["User-Agent"] = getPiUserAgent();
}

View file

@ -52,6 +52,7 @@ vi.mock("@anthropic-ai/sdk", () => {
return { default: FakeAnthropic };
});
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const neverAbortedSignal = new AbortController().signal;
const context: Context = {
@ -196,21 +197,20 @@ describe("Anthropic auth token env", () => {
});
describe("Anthropic-compatible user agents", () => {
it("enforces the pi runtime user agent for Kimi Coding", async () => {
await streamAnthropic(kimiCodingModel, context, {
apiKey: "kimi-key",
headers: { "user-agent": "custom-client" },
}).result();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
const userAgentHeaders = Object.entries(headers).filter(([name]) => name.toLowerCase() === "user-agent");
expect(userAgentHeaders).toEqual([["User-Agent", `pi (${platform()} ${release()}; ${arch()})`]]);
});
it("does not apply the pi runtime user agent to Anthropic", async () => {
it("uses pi's User-Agent by default for Anthropic Messages requests", async () => {
await streamAnthropic(anthropicModel, context, { apiKey: "anthropic-key" }).result();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
expect(Object.keys(headers).some((name) => name.toLowerCase() === "user-agent")).toBe(false);
expect(headers["User-Agent"]).toBe(PI_USER_AGENT);
});
it("lets explicit headers override the default Anthropic Messages User-Agent", async () => {
await streamAnthropic(kimiCodingModel, context, {
apiKey: "kimi-key",
headers: { "User-Agent": "custom-client" },
}).result();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
expect(headers["User-Agent"]).toBe("custom-client");
});
});

View file

@ -1,3 +1,4 @@
import { arch, platform, release } from "node:os";
import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { stream as streamAzureOpenAIResponses } from "../src/api/azure-openai-responses.ts";
@ -40,6 +41,8 @@ vi.mock("openai", () => {
return { AzureOpenAI };
});
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
@ -92,6 +95,17 @@ async function captureClientBaseUrl(baseUrl: string): Promise<string> {
return azureMock.constructorCalls[0].baseURL;
}
async function captureClientHeaders(headers?: Record<string, string>): Promise<Record<string, string>> {
const model = getModel("azure-openai-responses", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, context, {
apiKey: "test-api-key",
azureBaseUrl: "https://my-resource.openai.azure.com",
headers,
}).result();
expect(azureMock.constructorCalls).toHaveLength(1);
return azureMock.constructorCalls[0].defaultHeaders ?? {};
}
describe("azure-openai-responses base URL normalization", () => {
it("normalizes Cognitive Services root endpoints to /openai/v1", async () => {
const baseURL = await captureClientBaseUrl("https://marc-quicktests-resource.cognitiveservices.azure.com");
@ -201,3 +215,13 @@ describe("azure-openai-responses base URL normalization", () => {
expect(azureMock.constructorCalls[0].baseURL).toBe("https://my-resource.openai.azure.com/openai/v1");
});
});
describe("azure-openai-responses user agent", () => {
it("uses pi's User-Agent by default", async () => {
expect((await captureClientHeaders())["User-Agent"]).toBe(PI_USER_AGENT);
});
it("lets explicit headers override the default User-Agent", async () => {
expect((await captureClientHeaders({ "User-Agent": "custom-agent" }))["User-Agent"]).toBe("custom-agent");
});
});

View file

@ -0,0 +1,274 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* OpenAI models served through Bedrock Converse (e.g. `global.openai.gpt-5.6-terra`)
* return encrypted reasoning as the opaque `redactedContent` member of
* `reasoningContent`, not as `reasoningText`. The AWS SDK decodes the wire blob to
* `Uint8Array`.
* @see https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ReasoningContentBlockDelta.html
*/
const bedrockMock = vi.hoisted(() => {
const redactedBase64 = "cnNuXzVaVnJpZjRKMGJYSXFtV2RsZWRqN1FJRmVOaWtSUWJF";
return {
redactedBase64,
redactedBytes: new Uint8Array(Buffer.from(redactedBase64, "base64")),
streamEvents: undefined as unknown[] | undefined,
};
});
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
class BedrockRuntimeServiceException extends Error {}
class BedrockRuntimeClient {
send(): Promise<unknown> {
if (bedrockMock.streamEvents) {
const events = bedrockMock.streamEvents;
return Promise.resolve({
$metadata: { httpStatusCode: 200 },
stream: (async function* () {
yield* events;
})(),
});
}
return Promise.reject(new Error("mock send"));
}
}
class ConverseStreamCommand {
readonly input: unknown;
constructor(input: unknown) {
this.input = input;
}
}
return {
BedrockRuntimeClient,
BedrockRuntimeServiceException,
ConverseStreamCommand,
StopReason: {
END_TURN: "end_turn",
STOP_SEQUENCE: "stop_sequence",
MAX_TOKENS: "max_tokens",
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
TOOL_USE: "tool_use",
},
CachePointType: { DEFAULT: "default" },
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
};
});
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
import type { Context, Message, Model, ThinkingContent } from "../src/types.ts";
const gptModel: Model<"bedrock-converse-stream"> = {
id: "global.openai.gpt-5.6-terra",
name: "GPT-5.6 Terra (Global)",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.ap-northeast-1.amazonaws.com",
reasoning: true,
input: ["text"],
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const emptyUsage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
/** Mirrors the ConverseStream frames GPT-5.6 emits: encrypted reasoning, then text. */
function redactedReasoningEvents(): unknown[] {
return [
{ messageStart: { role: "assistant" } },
{
contentBlockDelta: {
contentBlockIndex: 0,
delta: { reasoningContent: { redactedContent: bedrockMock.redactedBytes } },
},
},
{ contentBlockStop: { contentBlockIndex: 0 } },
{ contentBlockDelta: { contentBlockIndex: 1, delta: { text: "done" } } },
{ contentBlockStop: { contentBlockIndex: 1 } },
{ messageStop: { stopReason: "end_turn" } },
];
}
interface BedrockRequestPayload {
messages: Array<{ role: string; content: Array<Record<string, unknown>> }>;
}
async function capturePayload(context: Context): Promise<BedrockRequestPayload> {
let capturedPayload: BedrockRequestPayload | undefined;
const s = streamBedrock(gptModel, context, {
cacheRetention: "none",
signal: AbortSignal.abort(),
onPayload: (payload) => {
capturedPayload = payload as BedrockRequestPayload;
return payload;
},
});
for await (const event of s) {
if (event.type === "error") break;
}
if (!capturedPayload) {
throw new Error("Expected Bedrock payload to be captured before request abort");
}
return capturedPayload;
}
describe("Bedrock redacted reasoning", () => {
beforeEach(() => {
bedrockMock.streamEvents = undefined;
});
it("does not fail the stream when reasoning arrives as redactedContent", async () => {
bedrockMock.streamEvents = redactedReasoningEvents();
const response = await streamBedrock(gptModel, {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
}).result();
expect(response.stopReason, response.errorMessage).not.toBe("error");
// Reasoning precedes the answer, matching the order Bedrock streamed it.
expect(response.content.map((c) => c.type)).toEqual(["thinking", "text"]);
expect(response.content[1]).toEqual({ type: "text", text: "done" });
});
it("preserves the encrypted reasoning payload on the assistant message", async () => {
bedrockMock.streamEvents = redactedReasoningEvents();
const response = await streamBedrock(gptModel, {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
}).result();
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
expect(thinking).toBeDefined();
// Same representation Anthropic redacted thinking already uses: the opaque
// payload rides in `thinkingSignature` with `redacted: true`.
expect(thinking?.redacted).toBe(true);
expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64);
// The byte buffer is streaming scratch state: persisting it would bloat the
// session, since a Uint8Array serializes to an index-keyed object.
expect("redactedChunks" in thinking!).toBe(false);
});
it("encodes the payload when the stream never sends contentBlockStop", async () => {
bedrockMock.streamEvents = [
{ messageStart: { role: "assistant" } },
{
contentBlockDelta: {
contentBlockIndex: 0,
delta: { reasoningContent: { redactedContent: bedrockMock.redactedBytes } },
},
},
{ messageStop: { stopReason: "end_turn" } },
];
const response = await streamBedrock(gptModel, {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
}).result();
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64);
// Streaming scratch state must not survive into the persisted message.
expect("redactedChunks" in thinking!).toBe(false);
expect("index" in thinking!).toBe(false);
});
it("joins encrypted reasoning split across deltas", async () => {
const [head, tail] = [bedrockMock.redactedBytes.slice(0, 7), bedrockMock.redactedBytes.slice(7)];
bedrockMock.streamEvents = [
{ messageStart: { role: "assistant" } },
{ contentBlockDelta: { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: head } } } },
{ contentBlockDelta: { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: tail } } } },
{ contentBlockStop: { contentBlockIndex: 0 } },
{ messageStop: { stopReason: "end_turn" } },
];
const response = await streamBedrock(gptModel, {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
}).result();
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64);
// The placeholder marks the block once, not once per delta.
expect(thinking?.thinking).toBe("[Reasoning redacted]");
});
it("replays redacted reasoning as reasoningContent.redactedContent", async () => {
const messages: Message[] = [
{ role: "user", content: "hello", timestamp: Date.now() },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "", thinkingSignature: bedrockMock.redactedBase64, redacted: true },
{ type: "text", text: "done" },
],
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: gptModel.id,
usage: emptyUsage,
stopReason: "stop",
timestamp: Date.now(),
},
{ role: "user", content: "continue", timestamp: Date.now() },
];
const payload = await capturePayload({ messages });
const assistant = payload.messages.find((m: any) => m.role === "assistant");
expect(assistant).toBeDefined();
expect(assistant!.content).toEqual([
{ reasoningContent: { redactedContent: bedrockMock.redactedBytes } },
{ text: "done" },
]);
});
it("replays redacted reasoning before the toolUse block it belongs to", async () => {
// Bedrock rejects a tool continuation whose reasoning block is missing or
// reordered, so the opaque payload must land ahead of the matching toolUse.
const messages: Message[] = [
{ role: "user", content: "read the file", timestamp: Date.now() },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "", thinkingSignature: bedrockMock.redactedBase64, redacted: true },
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "/tmp/a.txt" } },
],
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: gptModel.id,
usage: emptyUsage,
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "tool-1",
toolName: "read",
content: [{ type: "text", text: "file body" }],
isError: false,
timestamp: Date.now(),
},
];
const payload = await capturePayload({ messages });
const assistant = payload.messages.find((m: any) => m.role === "assistant");
expect(assistant).toBeDefined();
expect(assistant!.content).toEqual([
{ reasoningContent: { redactedContent: bedrockMock.redactedBytes } },
{ toolUse: { toolUseId: "tool-1", name: "read", input: { path: "/tmp/a.txt" } } },
]);
});
});

View file

@ -1,12 +1,18 @@
import { arch, platform, release } from "node:os";
import { describe, expect, it, vi } from "vitest";
const googleGenAiMock = vi.hoisted(() => ({
constructorCalls: [] as Array<Record<string, unknown>>,
finishReason: "MALFORMED_FUNCTION_CALL",
includeFunctionCall: false,
}));
vi.mock("@google/genai", () => {
class GoogleGenAI {
constructor(config: Record<string, unknown>) {
googleGenAiMock.constructorCalls.push(config);
}
models = {
generateContentStream: async function* () {
yield {
@ -84,10 +90,26 @@ import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts";
import { getModel } from "../src/compat.ts";
import type { Context } from "../src/types.ts";
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
async function captureGoogleHeaders(headers?: Record<string, string>): Promise<Record<string, string>> {
googleGenAiMock.constructorCalls.length = 0;
googleGenAiMock.finishReason = "STOP";
googleGenAiMock.includeFunctionCall = false;
await streamGoogleGenerativeAi(getModel("google", "gemini-2.5-flash"), context, {
apiKey: "test-api-key",
headers,
}).result();
expect(googleGenAiMock.constructorCalls).toHaveLength(1);
const httpOptions = googleGenAiMock.constructorCalls[0].httpOptions as { headers?: Record<string, string> };
return httpOptions.headers ?? {};
}
describe("Google raw stop reasons", () => {
it("preserves raw Gemini finish reasons for Google Generative AI errors", async () => {
googleGenAiMock.finishReason = "MALFORMED_FUNCTION_CALL";
@ -160,3 +182,13 @@ describe("Google raw stop reasons", () => {
expect(message.content.some((block) => block.type === "toolCall")).toBe(true);
});
});
describe("Google Generative AI user agent", () => {
it("uses pi's User-Agent by default", async () => {
expect((await captureGoogleHeaders())["User-Agent"]).toBe(PI_USER_AGENT);
});
it("lets explicit headers override the default User-Agent", async () => {
expect((await captureGoogleHeaders({ "User-Agent": "custom-agent" }))["User-Agent"]).toBe("custom-agent");
});
});

View file

@ -1,3 +1,4 @@
import { arch, platform, release } from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const googleGenAiMock = vi.hoisted(() => ({
@ -49,6 +50,7 @@ import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts";
import { getModel } from "../src/compat.ts";
import type { Context, Model } from "../src/types.ts";
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const model = getModel("google-vertex", "gemini-3-flash-preview");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
@ -154,7 +156,24 @@ describe("google-vertex api key resolution", () => {
await stream.result();
expect(googleGenAiMock.constructorCalls).toHaveLength(1);
expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toBeUndefined();
expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toEqual({
headers: { "User-Agent": PI_USER_AGENT },
});
});
it("lets explicit headers override the default User-Agent", async () => {
const stream = streamGoogleVertex(model, context, {
project: "test-project",
location: "us-central1",
headers: { "User-Agent": "custom-agent" },
});
await stream.result();
expect(googleGenAiMock.constructorCalls).toHaveLength(1);
expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toEqual({
headers: { "User-Agent": "custom-agent" },
});
});
it("forwards custom baseUrl to the ADC client", async () => {

View file

@ -1,9 +1,12 @@
import { arch, platform, release } from "node:os";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { stream as streamMistral } from "../src/api/mistral-conversations.ts";
import { getModel } from "../src/compat.ts";
import type { Context, FetchFunction, ProviderResponse } from "../src/types.ts";
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
function createSseResponse(events: unknown[], headers?: Record<string, string>): Response {
const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\r\n\r\n")}\r\n\r\ndata: [DONE]\r\n\r\n`;
return new Response(body, {
@ -109,6 +112,7 @@ describe("Mistral HTTP transport", () => {
expect(headers.get("accept")).toBe("text/event-stream");
expect(headers.get("x-affinity")).toBe("session-1");
expect(headers.get("x-custom")).toBe("value");
expect(headers.get("user-agent")).toBe(PI_USER_AGENT);
expect(callbackPayload?.maxTokens).toBe(123);
expect(callbackPayload?.promptMode).toBe("reasoning");
expect(callbackPayload?.promptCacheKey).toBe("session-1");
@ -356,11 +360,12 @@ describe("Mistral HTTP transport", () => {
apiKey: "request-key",
fetch,
sessionId: "automatic-affinity",
headers: { authorization: null, "x-affinity": null },
headers: { authorization: null, "x-affinity": null, "User-Agent": "custom-agent" },
}).result();
expect(requestHeaders?.has("authorization")).toBe(false);
expect(requestHeaders?.has("x-affinity")).toBe(false);
expect(requestHeaders?.get("user-agent")).toBe("custom-agent");
});
it("aborts while waiting for an SSE chunk", async () => {

View file

@ -38,6 +38,21 @@ vi.mock("openai", () => {
});
const reasoningDetail = { type: "reasoning.encrypted", id: "call_1", data: "encrypted-signature" };
const signedReasoningTextDetail = {
type: "reasoning.text",
text: "I should call the read tool.",
signature: "sha256:signed-text",
id: "reasoning-text-1",
format: "anthropic-claude-v1",
index: 0,
};
const reasoningSummaryDetail = {
type: "reasoning.summary",
summary: "Decided to inspect the requested file.",
id: "reasoning-summary-1",
format: "anthropic-claude-v1",
index: 1,
};
const readTool: Tool = {
name: "read",
description: "Read a file",
@ -84,9 +99,11 @@ async function runOpenAICompletionsStream(messages: AssistantMessage[] = []): Pr
return await streamOpenAICompletions(model(), { messages, tools: [readTool] }, { apiKey: "test" }).result();
}
function getAssistantPayload(payload: unknown): { reasoning_details?: unknown } | undefined {
const messages = (payload as { messages?: Array<{ role?: string; reasoning_details?: unknown }> }).messages ?? [];
return messages.find((message) => message.role === "assistant");
function getAssistantPayload(payload: unknown): { reasoning?: unknown; reasoning_details?: unknown } | undefined {
const messages = (
payload as { messages?: Array<{ role?: string; reasoning?: unknown; reasoning_details?: unknown }> }
).messages;
return messages?.find((message) => message.role === "assistant");
}
describe("openai-completions reasoning_details streaming", () => {
@ -95,24 +112,73 @@ describe("openai-completions reasoning_details streaming", () => {
mockState.payloads = [];
});
it("preserves reasoning_details that arrive before their matching tool call", async () => {
it("preserves reasoning_details in the thinking signature", async () => {
mockState.chunkSets = [
[chunk({ reasoning_details: [reasoningDetail] }), toolCallChunk(), chunk({}, "tool_calls")],
[chunk({ content: "ok" }), chunk({}, "stop")],
];
const assistantMessage = await runOpenAICompletionsStream();
const thinking = assistantMessage.content.find((block) => block.type === "thinking");
expect(thinking).toEqual({
type: "thinking",
thinking: "",
thinkingSignature: JSON.stringify([reasoningDetail]),
});
const toolCall = assistantMessage.content.find((block) => block.type === "toolCall");
expect(toolCall).toMatchObject({
expect(toolCall).toEqual({
type: "toolCall",
id: "call_1",
name: "read",
arguments: { path: "README.md" },
thoughtSignature: JSON.stringify(reasoningDetail),
});
await runOpenAICompletionsStream([assistantMessage]);
expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual([reasoningDetail]);
});
it("falls back to encrypted tool-call signatures for older stored assistant messages", async () => {
mockState.chunkSets = [
[chunk({ reasoning_details: [reasoningDetail] }), toolCallChunk(), chunk({}, "tool_calls")],
[chunk({ content: "ok" }), chunk({}, "stop")],
];
const assistantMessage = await runOpenAICompletionsStream();
assistantMessage.content = assistantMessage.content.filter((block) => block.type !== "thinking");
const toolCall = assistantMessage.content.find((block) => block.type === "toolCall");
if (!toolCall || toolCall.type !== "toolCall") throw new Error("Expected tool call");
toolCall.thoughtSignature = JSON.stringify(reasoningDetail);
await runOpenAICompletionsStream([assistantMessage]);
expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual([reasoningDetail]);
});
it("preserves signed text and summary reasoning_details in their original sequence", async () => {
mockState.chunkSets = [
[
chunk({ reasoning: signedReasoningTextDetail.text, reasoning_details: [signedReasoningTextDetail] }),
chunk({ reasoning_details: [reasoningDetail, reasoningSummaryDetail] }),
toolCallChunk(),
chunk({}, "tool_calls"),
],
[chunk({ content: "ok" }), chunk({}, "stop")],
];
const assistantMessage = await runOpenAICompletionsStream();
const expectedReasoningDetails = [signedReasoningTextDetail, reasoningDetail, reasoningSummaryDetail];
const thinking = assistantMessage.content.find((block) => block.type === "thinking");
expect(thinking).toEqual({
type: "thinking",
thinking: signedReasoningTextDetail.text,
thinkingSignature: JSON.stringify(expectedReasoningDetails),
});
await runOpenAICompletionsStream([assistantMessage]);
const payload = getAssistantPayload(mockState.payloads[1]);
expect(payload?.reasoning_details).toEqual(expectedReasoningDetails);
expect(payload?.reasoning).toBeUndefined();
});
});

View file

@ -295,15 +295,31 @@ describe("openai-completions tool_choice", () => {
expect(getModel("zai", "glm-5.2")?.compat?.zaiToolStream).toBe(true);
});
it("stores z.ai GLM-5.2 effort metadata", () => {
it("stores z.ai effort metadata", () => {
for (const provider of ["zai", "zai-coding-cn"] as const) {
const model = getModel(provider, "glm-5.2")!;
expect(model.compat?.supportsReasoningEffort).toBe(true);
expect(model.thinkingLevelMap).toEqual({
for (const modelId of ["glm-5.2", "glm-5.2-highspeed"] as const) {
const model = getModel(provider, modelId)!;
expect(model.compat?.supportsReasoningEffort).toBe(true);
expect(model.thinkingLevelMap).toEqual({
off: "none",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: "max",
});
}
const glm53 = getModel(provider, "glm-5.3")!;
expect(glm53.compat?.supportsReasoningEffort).toBe(true);
expect(glm53.thinkingLevelMap).toEqual({
off: null,
minimal: null,
low: "high",
medium: "high",
low: "low",
medium: null,
high: "high",
xhigh: null,
max: "max",
});
}

View file

@ -38,6 +38,53 @@ function completedResponse(): Response {
});
}
const customCompletionsModel: Model<"openai-completions"> = {
id: "grok-custom",
name: "Grok Custom",
api: "openai-completions",
provider: "xai",
baseUrl: "https://api.x.ai/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
};
async function captureCompletionsUserAgent(headers?: Record<string, string>): Promise<string | null> {
let userAgent: string | null = null;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
userAgent = new Request(input, init).headers.get("user-agent");
const chunks = [
{ id: "chatcmpl-ua", choices: [{ delta: { content: "ok" }, finish_reason: null, index: 0 }] },
{
id: "chatcmpl-ua",
choices: [{ delta: {}, finish_reason: "stop", index: 0 }],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
},
},
];
const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n")}\n\ndata: [DONE]\n\n`;
return new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
});
const result = await streamOpenAICompletions(
customCompletionsModel,
{ messages: [{ role: "user", content: "hello", timestamp: 1 }] },
{ apiKey: "xai-test-token", headers },
).result();
expect(result.stopReason, result.errorMessage).toBe("stop");
return userAgent;
}
async function captureRequest(
model: Model<"openai-responses">,
context: Context,
@ -184,7 +231,7 @@ describe("xAI Responses provider", () => {
});
});
it("keeps the SDK User-Agent for non-xAI Responses requests", async () => {
it("uses pi's User-Agent by default for Responses requests", async () => {
let userAgent: string | null = null;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
userAgent = new Request(input, init).headers.get("user-agent");
@ -202,54 +249,25 @@ describe("xAI Responses provider", () => {
{ apiKey: "test-token" },
).result();
expect(result.stopReason, result.errorMessage).toBe("stop");
expect(userAgent).not.toBeNull();
expect(userAgent).not.toBe(PI_USER_AGENT);
});
it("forces pi's User-Agent on custom xAI Completions models over caller headers", async () => {
let userAgent: string | null = null;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
userAgent = new Request(input, init).headers.get("user-agent");
const chunks = [
{ id: "chatcmpl-ua", choices: [{ delta: { content: "ok" }, finish_reason: null, index: 0 }] },
{
id: "chatcmpl-ua",
choices: [{ delta: {}, finish_reason: "stop", index: 0 }],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
},
},
];
const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n")}\n\ndata: [DONE]\n\n`;
return new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
});
const customModel: Model<"openai-completions"> = {
id: "grok-custom",
name: "Grok Custom",
api: "openai-completions",
provider: "xai",
baseUrl: "https://api.x.ai/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
};
const result = await streamOpenAICompletions(
customModel,
{ messages: [{ role: "user", content: "hello", timestamp: 1 }] },
{ apiKey: "xai-test-token", headers: { "User-Agent": "custom-agent" } },
).result();
expect(result.stopReason, result.errorMessage).toBe("stop");
expect(userAgent).toBe(PI_USER_AGENT);
});
it("lets explicit headers override the default Responses User-Agent", async () => {
const captured = await captureRequest(
XAI_MODELS["grok-4.5"],
{ messages: [{ role: "user", content: "hello", timestamp: 1 }] },
{ apiKey: "xai-test-token", headers: { "User-Agent": "custom-agent" } },
);
expect(captured.headers.get("user-agent")).toBe("custom-agent");
});
it("uses pi's User-Agent by default for Completions requests", async () => {
expect(await captureCompletionsUserAgent()).toBe(PI_USER_AGENT);
});
it("lets explicit headers override the default Completions User-Agent", async () => {
expect(await captureCompletionsUserAgent({ "User-Agent": "custom-agent" })).toBe("custom-agent");
});
});

View file

@ -12,6 +12,8 @@
### Fixed
- Fixed UTF-8 BOM markers preventing frontmatter and user configuration files from loading ([#8337](https://github.com/earendil-works/pi/issues/8337)).
- Fixed invalid settings files being easy to miss during interactive startup by rendering warnings with the file path inside the TUI ([#7829](https://github.com/earendil-works/pi/issues/7829)).
- Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)).
- Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)).
- Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)).
@ -23,6 +25,8 @@
- Fixed llama.cpp login guidance to direct users to `/llama` before `/model` when no local models are loaded ([#8203](https://github.com/earendil-works/pi/issues/8203)).
- Fixed hung pi.dev model catalog requests consuming the entire refresh deadline without retrying ([#8198](https://github.com/earendil-works/pi/issues/8198)).
- Fixed inherited Xiaomi model catalogs listing shut-down MiMo V2 models in `/model` and `--list-models` ([#8187](https://github.com/earendil-works/pi/issues/8187)).
- Fixed branch summary entries recording the navigation destination in `fromId` instead of the pre-navigation source leaf.
- Fixed threshold auto-compaction being skipped when providers omit streaming usage data ([#8328](https://github.com/earendil-works/pi/issues/8328)).
## [0.84.2] - 2026-08-14

View file

@ -281,9 +281,7 @@ pi.on("session_before_compact", async (event, ctx) => {
const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event;
// preparation.messagesToSummarize - messages to summarize
// preparation.sourceMessages - optional active-context history prefix, including any previous summary
// preparation.turnPrefixMessages - split turn prefix (if isSplitTurn)
// preparation.turnPrefixSourceMessages - optional active-context prefix through the split turn
// preparation.previousSummary - previous compaction summary
// preparation.fileOps - extracted file operations
// preparation.tokensBefore - context tokens before compaction

View file

@ -15,12 +15,16 @@ except that streaming message updates omit cumulative snapshots:
```typescript
type WithoutPartial<T> = T extends { partial: unknown } ? Omit<T, "partial"> : T;
type JsonAssistantMessageEvent<T> = T extends { type: "toolcall_start"; partial: unknown }
? WithoutPartial<T> & { id: string; toolName: string }
: WithoutPartial<T>;
type JsonAgentSessionEvent =
| Exclude<AgentSessionEvent, { type: "message_update" }>
| {
type: "message_update";
usage: Usage;
assistantMessageEvent: WithoutPartial<AssistantMessageEvent>;
assistantMessageEvent: JsonAssistantMessageEvent<AssistantMessageEvent>;
};
```
@ -84,7 +88,8 @@ Followed by events as they occur:
`assistantMessageEvent.partial` to keep stream size linear. The top-level `usage` field contains
the latest cumulative provider-reported usage and may remain zero when a provider only reports
usage at completion. Use `contentIndex` and `delta` to assemble live text, thinking, or tool-call
arguments if needed. `message_end` contains the final authoritative message.
arguments if needed. A `toolcall_start` event also includes the constant-sized `id` and `toolName`
fields. `message_end` contains the final authoritative message.
## Example

View file

@ -945,7 +945,7 @@ The `assistantMessageEvent` field contains one of these delta types:
| `thinking_start` | Thinking block started |
| `thinking_delta` | Thinking content chunk |
| `thinking_end` | Thinking block ended |
| `toolcall_start` | Tool call started |
| `toolcall_start` | Tool call started (includes `id` and `toolName`) |
| `toolcall_delta` | Tool call arguments chunk |
| `toolcall_end` | Tool call ended (includes full `toolCall` object) |
@ -960,11 +960,17 @@ Example streaming a text response:
The top-level `usage` field contains the latest cumulative provider-reported usage. It may remain
zero until completion when a provider does not report usage during streaming.
Example starting a tool call:
```json
{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"toolcall_start","contentIndex":1,"id":"call_abc123","toolName":"write"}}
```
`message_update` intentionally omits the former cumulative `message` field and
`assistantMessageEvent.partial`. Clients that need a live partial message must assemble it
from `message_start` and subsequent events using `contentIndex`. Treat `message_end.message`
as authoritative. For tool calls, buffer `toolcall_delta.delta`; `toolcall_end.toolCall`
contains the completed call.
as authoritative. For tool calls, `toolcall_start` provides the call `id` and `toolName`;
buffer `toolcall_delta.delta` for arguments. `toolcall_end.toolCall` contains the completed
call.
### bash_execution_update

View file

@ -9,6 +9,7 @@ import { resolve } from "path";
import { resolveReadPath } from "../core/tools/path-utils.ts";
import { processImage } from "../utils/image-process.ts";
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.ts";
import { stripBom } from "../utils/text.ts";
export interface ProcessedFiles {
text: string;
@ -73,7 +74,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process
} else {
// Handle text file
try {
const content = await readFile(absolutePath, "utf-8");
const content = stripBom(await readFile(absolutePath, "utf-8"));
text += `<file name="${absolutePath}">\n${content}\n</file>\n`;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);

View file

@ -4,6 +4,7 @@ import { basename, dirname, join, resolve, sep, win32 } from "path";
import { fileURLToPath } from "url";
import { spawnProcessSync } from "./utils/child-process.ts";
import { normalizePath } from "./utils/paths.ts";
import { stripBom } from "./utils/text.ts";
// =============================================================================
// Package Detection
@ -478,7 +479,7 @@ interface PackageJson {
let pkg: PackageJson = {};
try {
pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson;
pkg = JSON.parse(stripBom(readFileSync(getPackageJsonPath(), "utf-8"))) as PackageJson;
} catch (e: unknown) {
const err = e as NodeJS.ErrnoException;
if (err.code !== "ENOENT") throw e;

View file

@ -65,7 +65,7 @@ import {
prepareCompaction,
shouldCompact,
} from "./compaction/index.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.ts";
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts";
import {
@ -252,6 +252,12 @@ export interface PromptOptions {
preflightResult?: (success: boolean) => void;
}
/** Options for model/thinking mutations. */
export interface ModelMutationOptions {
/** Persist the new value to global defaults. Defaults to session-only. */
persist?: boolean;
}
/** Result from cycleModel() */
export interface ModelCycleResult {
model: Model<any>;
@ -297,9 +303,6 @@ function estimateMessagesTokens(messages: AgentMessage[]): number {
// Constants
// ============================================================================
/** Standard thinking levels */
const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"];
// ============================================================================
// AgentSession Class
// ============================================================================
@ -1588,21 +1591,26 @@ export class AgentSession {
/**
* Set model directly.
* Validates that auth is configured, saves to session and settings.
* Validates that auth is configured and saves to the session transcript.
* Persists to global defaults only when options.persist is true.
* @throws Error if no auth is configured for the model
*/
async setModel(model: Model<any>): Promise<void> {
async setModel(model: Model<any>, options: ModelMutationOptions = {}): Promise<void> {
if (!(await this._modelRuntime.checkAuth(model.provider))) {
throw new Error(`No API key for ${model.provider}/${model.id}`);
}
const previousModel = this.model;
const thinkingLevel = this._getThinkingLevelForModelSwitch();
const thinkingLevel = this._getThinkingLevelForModelSwitch(model);
this.agent.state.model = model;
this.sessionManager.appendModelChange(model.provider, model.id);
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
if (options.persist) {
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
}
// Re-clamp thinking level for new model's capabilities
// Re-clamp session thinking level for new model's capabilities.
// Per-model thinking level overrides take priority over session carry-over.
// Model persistence does not implicitly rewrite the global thinking default.
this.setThinkingLevel(thinkingLevel);
await this._emitModelSelect(model, previousModel, "set");
@ -1614,14 +1622,20 @@ export class AgentSession {
* @param direction - "forward" (default) or "backward"
* @returns The new model info, or undefined if only one model available
*/
async cycleModel(direction: "forward" | "backward" = "forward"): Promise<ModelCycleResult | undefined> {
async cycleModel(
direction: "forward" | "backward" = "forward",
options: ModelMutationOptions = {},
): Promise<ModelCycleResult | undefined> {
if (this._scopedModels.length > 0) {
return this._cycleScopedModel(direction);
return this._cycleScopedModel(direction, options);
}
return this._cycleAvailableModel(direction);
return this._cycleAvailableModel(direction, options);
}
private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
private async _cycleScopedModel(
direction: "forward" | "backward",
options: ModelMutationOptions,
): Promise<ModelCycleResult | undefined> {
const availableIds = new Set(
this._modelRuntime.getAvailableSnapshot().map((model) => `${model.provider}\0${model.id}`),
);
@ -1637,17 +1651,20 @@ export class AgentSession {
const len = scopedModels.length;
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
const next = scopedModels[nextIndex];
const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel);
const thinkingLevel = this._getThinkingLevelForModelSwitch(next.model, next.thinkingLevel);
// Apply model
this.agent.state.model = next.model;
this.sessionManager.appendModelChange(next.model.provider, next.model.id);
this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
if (options.persist) {
this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
}
// Apply thinking level.
// Apply session thinking level.
// - Explicit scoped model thinking level overrides current session level
// - Undefined scoped model thinking level inherits the current session preference
// setThinkingLevel clamps to model capabilities.
// Model persistence does not implicitly rewrite the global thinking default.
this.setThinkingLevel(thinkingLevel);
await this._emitModelSelect(next.model, currentModel, "cycle");
@ -1655,7 +1672,10 @@ export class AgentSession {
return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
}
private async _cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
private async _cycleAvailableModel(
direction: "forward" | "backward",
options: ModelMutationOptions,
): Promise<ModelCycleResult | undefined> {
const availableModels = this._modelRuntime.getAvailableSnapshot();
if (availableModels.length <= 1) return undefined;
@ -1667,12 +1687,15 @@ export class AgentSession {
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
const nextModel = availableModels[nextIndex];
const thinkingLevel = this._getThinkingLevelForModelSwitch();
const thinkingLevel = this._getThinkingLevelForModelSwitch(nextModel);
this.agent.state.model = nextModel;
this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
if (options.persist) {
this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
}
// Re-clamp thinking level for new model's capabilities
// Re-clamp session thinking level for new model's capabilities.
// Model persistence does not implicitly rewrite the global thinking default.
this.setThinkingLevel(thinkingLevel);
await this._emitModelSelect(nextModel, currentModel, "cycle");
@ -1687,9 +1710,10 @@ export class AgentSession {
/**
* Set thinking level.
* Clamps to model capabilities based on available thinking levels.
* Saves to session and settings only if the level actually changes.
* Saves the clamped level to the session transcript only if the level actually changes.
* Persists the requested level to global defaults only when options.persist is true.
*/
setThinkingLevel(level: ThinkingLevel): void {
setThinkingLevel(level: ThinkingLevel, options: ModelMutationOptions = {}): void {
const availableLevels = this.getAvailableThinkingLevels();
const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels);
@ -1699,11 +1723,12 @@ export class AgentSession {
this.agent.state.thinkingLevel = effectiveLevel;
if (options.persist) {
this.settingsManager.setDefaultThinkingLevel(level);
}
if (isChanging) {
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
if (this.supportsThinking() || effectiveLevel !== "off") {
this.settingsManager.setDefaultThinkingLevel(effectiveLevel);
}
this._emit({ type: "thinking_level_changed", level: effectiveLevel });
void this._extensionRunner.emit({
type: "thinking_level_select",
@ -1717,7 +1742,7 @@ export class AgentSession {
* Cycle to next thinking level.
* @returns New level, or undefined if model doesn't support thinking
*/
cycleThinkingLevel(): ThinkingLevel | undefined {
cycleThinkingLevel(options: ModelMutationOptions = {}): ThinkingLevel | undefined {
if (!this.supportsThinking()) return undefined;
const levels = this.getAvailableThinkingLevels();
@ -1725,7 +1750,7 @@ export class AgentSession {
const nextIndex = (currentIndex + 1) % levels.length;
const nextLevel = levels[nextIndex];
this.setThinkingLevel(nextLevel);
this.setThinkingLevel(nextLevel, options);
return nextLevel;
}
@ -1734,7 +1759,7 @@ export class AgentSession {
* The provider will clamp to what the specific model supports internally.
*/
getAvailableThinkingLevels(): ThinkingLevel[] {
if (!this.model) return THINKING_LEVELS;
if (!this.model) return [...THINKING_LEVEL_OPTIONS];
return getSupportedThinkingLevels(this.model) as ThinkingLevel[];
}
@ -1745,10 +1770,17 @@ export class AgentSession {
return !!this.model?.reasoning;
}
private _getThinkingLevelForModelSwitch(explicitLevel?: ThinkingLevel): ThinkingLevel {
private _getThinkingLevelForModelSwitch(targetModel?: Model<any>, explicitLevel?: ThinkingLevel): ThinkingLevel {
if (explicitLevel !== undefined) {
return explicitLevel;
}
// Per-model default takes priority when switching to a model that has one
if (targetModel) {
const perModel = this.settingsManager.getModelThinkingLevel(targetModel.provider, targetModel.id);
if (perModel !== undefined) {
return perModel;
}
}
if (!this.supportsThinking()) {
return this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;
}
@ -1813,7 +1845,7 @@ export class AgentSession {
env,
this.settingsManager.getRetrySettings(),
this._summarizationRetryCallbacks({ source: "compaction", reason }),
undefined, // cacheFriendly
undefined, // sessionId
);
}
@ -2099,17 +2131,20 @@ export class AgentSession {
if (assistantMessage.stopReason === "error" || directContextTokens === 0) {
const messages = this.agent.state.messages;
const estimate = estimateContextTokens(messages);
if (estimate.lastUsageIndex === null) return false; // No usage data at all
// Verify the usage source is post-compaction. Kept pre-compaction messages
// have stale usage reflecting the old (larger) context and would falsely
// trigger compaction right after one just finished.
const usageMsg = messages[estimate.lastUsageIndex];
if (
compactionEntry &&
usageMsg.role === "assistant" &&
(usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime()
) {
return false;
// Without provider usage, estimate.tokens is the pure message-size estimate.
// Only usage-backed estimates need the stale pre-compaction check.
if (estimate.lastUsageIndex !== null) {
// Verify the usage source is post-compaction. Kept pre-compaction messages
// have stale usage reflecting the old (larger) context and would falsely
// trigger compaction right after one just finished.
const usageMsg = messages[estimate.lastUsageIndex];
if (
compactionEntry &&
usageMsg.role === "assistant" &&
(usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime()
) {
return false;
}
}
contextTokens = estimate.tokens;
} else {

View file

@ -11,6 +11,7 @@ import { setTimeout as sleep } from "timers/promises";
import { getAgentDir } from "../config.ts";
import { raceWithAbortSignal } from "../utils/abort.ts";
import { getFileRevision, normalizePath } from "../utils/paths.ts";
import { stripBom } from "../utils/text.ts";
import { isCommandConfigValue, resolveConfigValue } from "./resolve-config-value.ts";
type AuthStorageData = Record<string, Credential>;
@ -214,7 +215,7 @@ export class ReadOnlyAuthStorage implements CredentialStore {
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(this.authPath, "utf-8"));
parsed = JSON.parse(stripBom(readFileSync(this.authPath, "utf-8")));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
this.data = {};
@ -364,7 +365,7 @@ export class AuthStorage implements CredentialStore {
if (!content) {
return {};
}
return JSON.parse(content) as AuthStorageData;
return JSON.parse(stripBom(content)) as AuthStorageData;
}
private updateReadState(data: AuthStorageData, revision?: string): void {
@ -499,7 +500,7 @@ export function readStoredCredential(
authPath: string = join(getAgentDir(), "auth.json"),
): Credential | undefined {
try {
const data = JSON.parse(readFileSync(normalizePath(authPath), "utf-8")) as AuthStorageData;
const data = JSON.parse(stripBom(readFileSync(normalizePath(authPath), "utf-8"))) as AuthStorageData;
return data[providerId];
} catch {
return undefined;

View file

@ -6,14 +6,7 @@
*/
import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core";
import {
contentText,
estimateContextTokens as estimateProviderContextTokens,
type RetryCallbacks,
type RetryPolicy,
retryAssistantCall,
uuidv7,
} from "@earendil-works/pi-ai";
import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall, uuidv7 } from "@earendil-works/pi-ai";
import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
import { completeSimple } from "@earendil-works/pi-ai/compat";
import { convertToLlm } from "../messages.ts";
@ -33,17 +26,6 @@ import {
serializeConversation,
} from "./utils.ts";
function getAnthropicSummarizationFallback(model: Model<any>): SimpleStreamOptions["refusalFallbacks"] {
if (model.provider !== "anthropic" || model.api !== "anthropic-messages") {
return undefined;
}
const allowedFallbackModels = (model as Model<"anthropic-messages">).compat?.allowedFallbackModels;
// Use the primary permitted fallback for now. If future Anthropic models expose
// broader fallback behavior, this can become a user/config pick or a full chain.
return allowedFallbackModels && allowedFallbackModels.length > 0 ? [allowedFallbackModels[0]] : undefined;
}
// ============================================================================
// File Operation Tracking
// ============================================================================
@ -102,27 +84,6 @@ function getMessageFromEntryForCompaction(entry: SessionEntry): AgentMessage | u
return sessionEntryToContextMessages(entry)[0];
}
/** Build an active-context prefix, placing the previous compaction summary before its retained messages. */
function collectSourceMessages(
entries: SessionEntry[],
startIndex: number,
endIndex: number,
previousCompactionIndex: number,
): AgentMessage[] {
const messages: AgentMessage[] = [];
if (previousCompactionIndex >= 0) {
messages.push(...sessionEntryToContextMessages(entries[previousCompactionIndex]));
}
for (let i = startIndex; i < endIndex; i++) {
// The latest compaction was moved to the front above. Keep older compaction
// entries because buildSessionContext retains them in the active provider prefix.
if (i !== previousCompactionIndex) {
messages.push(...sessionEntryToContextMessages(entries[i]));
}
}
return messages;
}
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */
export interface CompactionResult<T = unknown> {
summary: string;
@ -168,19 +129,6 @@ export interface CompactionSettings {
keepRecentTokens: number;
}
/** Active provider contexts and request settings used to preserve cacheable compaction prefixes. */
export interface CacheFriendlySummaryOptions {
/** Exact provider context prefix containing the history to summarize. */
sourceContext?: Context;
/** Exact provider context prefix containing a split turn's prefix. */
turnPrefixSourceContext?: Context;
/** Provider request settings copied from the active agent request path. */
requestOptions?: Pick<
SimpleStreamOptions,
"sessionId" | "onPayload" | "onResponse" | "transport" | "thinkingBudgets" | "maxRetryDelayMs"
>;
}
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
enabled: true,
reserveTokens: 16384,
@ -590,10 +538,6 @@ const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation mes
${UPDATE_SUMMARIZATION_INSTRUCTIONS}`;
const SOURCE_CONTEXT_UPDATE_SUMMARIZATION_PROMPT = `The messages above contain an existing structured summary of earlier conversation history followed by NEW conversation messages.
${UPDATE_SUMMARIZATION_INSTRUCTIONS}`;
function createSummarizationOptions(
model: Model<any>,
maxTokens: number,
@ -602,22 +546,9 @@ function createSummarizationOptions(
env: Record<string, string> | undefined,
signal: AbortSignal | undefined,
thinkingLevel: ThinkingLevel | undefined,
requestOptions: CacheFriendlySummaryOptions["requestOptions"] | undefined,
cacheRetention: SimpleStreamOptions["cacheRetention"] | undefined,
sessionId: string | undefined,
): SimpleStreamOptions {
const options: SimpleStreamOptions = {
...requestOptions,
maxTokens,
signal,
apiKey,
headers,
env,
cacheRetention,
};
const refusalFallbacks = getAnthropicSummarizationFallback(model);
if (refusalFallbacks) {
options.refusalFallbacks = refusalFallbacks;
}
const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env, sessionId };
if (model.reasoning && thinkingLevel && thinkingLevel !== "off") {
options.reasoning = thinkingLevel;
}
@ -639,15 +570,12 @@ export async function completeSummarization(
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
): Promise<AssistantMessage> {
// Standalone one-off summaries default to no prompt caching. Cache-friendly callers
// explicitly request short retention so providers can reuse the active prefix.
// Callers without a session ID, including standalone branch summaries, receive a fresh routing ID.
// Avoid cache writes for one-off summaries. Reuse caller-supplied routing when available;
// callers without a session ID, including branch summaries, receive a fresh routing ID.
const requestOptions: SimpleStreamOptions = {
...options,
cacheRetention: options.cacheRetention ?? "none",
cacheRetention: "none",
sessionId: options.sessionId ?? uuidv7(),
// Anthropic invalidates the messages cache when tool_choice changes. Its 20-content-block lookup
// can still reuse an earlier user-message checkpoint, but long tool-heavy turns may need reprocessing.
toolChoice: "none",
};
const produce = async (): Promise<AssistantMessage> =>
@ -675,7 +603,7 @@ export async function generateSummary(
env?: Record<string, string>,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
cacheFriendly?: Pick<CacheFriendlySummaryOptions, "sourceContext" | "requestOptions">,
sessionId?: string,
): Promise<string> {
return (
await generateSummaryWithUsage(
@ -692,47 +620,25 @@ export async function generateSummary(
env,
retry,
callbacks,
cacheFriendly,
sessionId,
)
).text;
}
/** Build a standalone summary request or append its instruction to an existing provider context. */
function buildSummarizationContext(promptText: string, sourceContext?: Context): Context {
const instructionMessage = {
role: "user" as const,
content: [{ type: "text" as const, text: promptText }],
timestamp: Date.now(),
};
if (sourceContext) {
return {
...sourceContext,
messages: [...sourceContext.messages, instructionMessage],
};
}
/** Build the provider context for a standalone summary request. */
function buildSummarizationContext(promptText: string): Context {
return {
systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
messages: [instructionMessage],
messages: [
{
role: "user",
content: [{ type: "text", text: promptText }],
timestamp: Date.now(),
},
],
};
}
/**
* Extra room for provider framing and tokenizer variance omitted by the heuristic context estimate.
* This matches the 4096-token margin used when normal simple requests clamp maxTokens to their context window.
*/
const CACHE_FRIENDLY_CONTEXT_SAFETY_TOKENS = 4096;
/** Whether the source context leaves room for the requested summary output and provider safety margin. */
function cacheFriendlyContextFits(model: Model<any>, context: Context, maxTokens: number): boolean {
return (
model.contextWindow <= 0 ||
estimateProviderContextTokens(context).tokens + maxTokens + CACHE_FRIENDLY_CONTEXT_SAFETY_TOKENS <=
model.contextWindow
);
}
/** Generate or update a conversation summary and return its provider usage. */
export async function generateSummaryWithUsage(
currentMessages: AgentMessage[],
@ -748,55 +654,31 @@ export async function generateSummaryWithUsage(
env?: Record<string, string>,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
cacheFriendly?: Pick<CacheFriendlySummaryOptions, "sourceContext" | "requestOptions">,
sessionId?: string,
): Promise<{ text: string; usage: Usage }> {
const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
);
// Provider-visible history prefix to reuse instead of serializing messages into a standalone prompt.
let sourceContext = cacheFriendly?.sourceContext;
// Cache-friendly source contexts already contain the previous compaction summary,
// but still need iterative-update instructions so prior information is preserved.
let basePrompt = previousSummary
? sourceContext
? SOURCE_CONTEXT_UPDATE_SUMMARIZATION_PROMPT
: UPDATE_SUMMARIZATION_PROMPT
: SUMMARIZATION_PROMPT;
// Use update prompt if we have a previous summary, otherwise initial prompt
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
if (customInstructions) {
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
}
// A valid prefix can still be too large to leave the intended summary budget,
// especially during overflow recovery or after switching to a smaller-context model.
// In that case, use standalone serialization, which truncates large tool results.
if (
sourceContext &&
!cacheFriendlyContextFits(model, buildSummarizationContext(basePrompt, sourceContext), maxTokens)
) {
sourceContext = undefined;
basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
if (customInstructions) {
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
}
}
// Serialize conversation to text so model doesn't try to continue it
// Convert to LLM messages first (handles custom types like bashExecution, custom, etc.)
const llmMessages = convertToLlm(currentMessages);
const conversationText = serializeConversation(llmMessages);
// Source contexts already contain the conversation. Standalone requests serialize it
// so the model treats those messages as data rather than continuing the conversation.
let promptText = "";
if (!sourceContext) {
const llmMessages = convertToLlm(currentMessages);
const conversationText = serializeConversation(llmMessages);
promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
}
if (previousSummary && !sourceContext) {
// Build the prompt with conversation wrapped in tags
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
if (previousSummary) {
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
}
promptText += basePrompt;
const sourceRequestOptions = sourceContext ? cacheFriendly?.requestOptions : undefined;
const sourceCacheRetention = sourceContext ? "short" : undefined;
const completionOptions = createSummarizationOptions(
model,
maxTokens,
@ -805,13 +687,12 @@ export async function generateSummaryWithUsage(
env,
signal,
thinkingLevel,
sourceRequestOptions,
sourceCacheRetention,
sessionId,
);
const response = await completeSummarization(
model,
buildSummarizationContext(promptText, sourceContext),
buildSummarizationContext(promptText),
completionOptions,
streamFn,
retry,
@ -839,15 +720,8 @@ export interface CompactionPreparation {
firstKeptEntryId: string;
/** Messages that will be summarized and discarded */
messagesToSummarize: AgentMessage[];
/**
* Active-context prefix for the history summary.
* Includes the previous compaction summary before messages retained by that compaction.
*/
sourceMessages?: AgentMessage[];
/** Messages that will be turned into turn prefix summary (if splitting) */
turnPrefixMessages: AgentMessage[];
/** Active-context prefix through the split-turn prefix, or empty when not splitting. */
turnPrefixSourceMessages?: AgentMessage[];
/** Whether this is a split turn (cut point in middle of turn) */
isSplitTurn: boolean;
tokensBefore: number;
@ -905,8 +779,6 @@ export function prepareCompaction(
if (msg) messagesToSummarize.push(msg);
}
const sourceMessages = collectSourceMessages(pathEntries, boundaryStart, historyEnd, prevCompactionIndex);
// Messages for turn prefix summary (if splitting a turn)
const turnPrefixMessages: AgentMessage[] = [];
if (cutPoint.isSplitTurn) {
@ -915,9 +787,6 @@ export function prepareCompaction(
if (msg) turnPrefixMessages.push(msg);
}
}
const turnPrefixSourceMessages = cutPoint.isSplitTurn
? collectSourceMessages(pathEntries, boundaryStart, cutPoint.firstKeptEntryIndex, prevCompactionIndex)
: [];
if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
return undefined;
@ -936,9 +805,7 @@ export function prepareCompaction(
return {
firstKeptEntryId,
messagesToSummarize,
sourceMessages,
turnPrefixMessages,
turnPrefixSourceMessages,
isSplitTurn: cutPoint.isSplitTurn,
tokensBefore,
previousSummary,
@ -966,30 +833,13 @@ Summarize the prefix to provide context for the retained suffix:
Be concise. Focus on what's needed to understand the kept suffix.`;
const SOURCE_CONTEXT_TURN_PREFIX_SUMMARIZATION_PROMPT = `The final turn in the source conversation was too large to keep in full. Its SUFFIX (recent work) is retained.
The source conversation may also contain complete earlier turns for background. Summarize only the final, incomplete turn. It begins with the last user-role request before this instruction. Do not summarize earlier turns except for details needed to understand this final turn's prefix.
Summarize the prefix to provide context for the retained suffix:
## Original Request
[What did the user ask for in this turn?]
## Early Progress
- [Key decisions and work done in the prefix]
## Context for Suffix
- [Information needed to understand the retained recent work]
Be concise. Focus on what's needed to understand the kept suffix.`;
/**
* Generate summaries for compaction using prepared data.
* Returns CompactionResult - SessionManager adds uuid/parentUuid when saving.
*
* @param preparation - Pre-calculated preparation from prepareCompaction()
* @param customInstructions - Optional custom focus for the summary
* @param cacheFriendly - Active provider contexts and request settings for cache-friendly summarization
* @param sessionId - Optional routing session ID forwarded without enabling prompt caching
*/
export async function compact(
preparation: CompactionPreparation,
@ -1003,7 +853,7 @@ export async function compact(
env?: Record<string, string>,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
cacheFriendly?: CacheFriendlySummaryOptions,
sessionId?: string,
): Promise<CompactionResult> {
const {
firstKeptEntryId,
@ -1038,10 +888,7 @@ export async function compact(
env,
retry,
callbacks,
{
sourceContext: cacheFriendly?.sourceContext,
requestOptions: cacheFriendly?.requestOptions,
},
sessionId,
);
historyText = historyResult.text;
historyUsage = historyResult.usage;
@ -1058,10 +905,7 @@ export async function compact(
streamFn,
retry,
callbacks,
{
sourceContext: cacheFriendly?.turnPrefixSourceContext,
requestOptions: cacheFriendly?.requestOptions,
},
sessionId,
);
// Merge into single summary
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`;
@ -1082,10 +926,7 @@ export async function compact(
env,
retry,
callbacks,
{
sourceContext: cacheFriendly?.sourceContext,
requestOptions: cacheFriendly?.requestOptions,
},
sessionId,
);
summary = result.text;
summaryUsage = result.usage;
@ -1123,51 +964,20 @@ async function generateTurnPrefixSummary(
streamFn?: StreamFn,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
cacheFriendly?: Pick<CacheFriendlySummaryOptions, "sourceContext" | "requestOptions">,
sessionId?: string,
): Promise<{ text: string; usage: Usage }> {
const maxTokens = Math.min(
Math.floor(0.5 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
); // Smaller budget for turn prefix
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
// Reuse the provider-visible split-turn prefix only when it leaves room for the summary;
// otherwise serialize and truncate the messages in a standalone prompt.
let sourceContext = cacheFriendly?.sourceContext;
if (
sourceContext &&
!cacheFriendlyContextFits(
model,
buildSummarizationContext(SOURCE_CONTEXT_TURN_PREFIX_SUMMARIZATION_PROMPT, sourceContext),
maxTokens,
)
) {
sourceContext = undefined;
}
let promptText: string;
if (sourceContext) {
promptText = SOURCE_CONTEXT_TURN_PREFIX_SUMMARIZATION_PROMPT;
} else {
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
}
const sourceRequestOptions = sourceContext ? cacheFriendly?.requestOptions : undefined;
const sourceCacheRetention = sourceContext ? "short" : undefined;
const response = await completeSummarization(
model,
buildSummarizationContext(promptText, sourceContext),
createSummarizationOptions(
model,
maxTokens,
apiKey,
headers,
env,
signal,
thinkingLevel,
sourceRequestOptions,
sourceCacheRetention,
),
buildSummarizationContext(promptText),
createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel, sessionId),
streamFn,
retry,
callbacks,

View file

@ -1,3 +1,12 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium";
export const THINKING_LEVEL_OPTIONS: readonly ThinkingLevel[] = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
];

View file

@ -9,6 +9,7 @@ import {
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { getAgentDir } from "../config.ts";
import { stripBom } from "../utils/text.ts";
export interface AppKeybindings {
"app.interrupt": true;
@ -329,7 +330,7 @@ function orderKeybindingsConfig(config: Record<string, unknown>): Record<string,
function loadRawConfig(path: string): Record<string, unknown> | undefined {
if (!existsSync(path)) return undefined;
try {
const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
const parsed = JSON.parse(stripBom(readFileSync(path, "utf-8"))) as unknown;
if (typeof parsed !== "object" || parsed === null) return undefined;
return parsed as Record<string, unknown>;
} catch {

View file

@ -6,6 +6,7 @@ import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { stripJsonComments } from "../utils/json.ts";
import { normalizePath } from "../utils/paths.ts";
import { stripBom } from "../utils/text.ts";
const PercentileCutoffsSchema = Type.Object({
p50: Type.Optional(Type.Number()),
@ -259,7 +260,7 @@ export class ModelConfig {
let parsed: unknown;
try {
parsed = JSON.parse(stripJsonComments(content));
parsed = JSON.parse(stripJsonComments(stripBom(content)));
} catch (error) {
return new ModelConfig(
new Map(),

View file

@ -626,6 +626,7 @@ export async function findInitialModel(options: {
defaultProvider?: string;
defaultModelId?: string;
defaultThinkingLevel?: ThinkingLevel;
modelThinkingLevels?: Record<string, ThinkingLevel>;
modelRuntime: ModelRuntime;
}): Promise<InitialModelResult> {
const {
@ -636,6 +637,7 @@ export async function findInitialModel(options: {
defaultProvider,
defaultModelId,
defaultThinkingLevel,
modelThinkingLevels,
modelRuntime,
} = options;
@ -660,9 +662,11 @@ export async function findInitialModel(options: {
// 2. Use first model from scoped models (skip if continuing/resuming)
if (scopedModels.length > 0 && !isContinuing) {
const scopedModel = scopedModels[0];
const perModel = modelThinkingLevels?.[`${scopedModel.model.provider}/${scopedModel.model.id}`];
return {
model: scopedModels[0].model,
thinkingLevel: scopedModels[0].thinkingLevel ?? defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL,
model: scopedModel.model,
thinkingLevel: scopedModel.thinkingLevel ?? perModel ?? defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL,
fallbackMessage: undefined,
};
}
@ -672,7 +676,10 @@ export async function findInitialModel(options: {
const found = modelRuntime.getModel(defaultProvider, defaultModelId);
if (found && modelRuntime.hasConfiguredAuth(found.provider)) {
model = found;
if (defaultThinkingLevel) {
const perModel = modelThinkingLevels?.[`${defaultProvider}/${defaultModelId}`];
if (perModel) {
thinkingLevel = perModel;
} else if (defaultThinkingLevel) {
thinkingLevel = defaultThinkingLevel;
}
return { model, thinkingLevel, fallbackMessage: undefined };

View file

@ -3,6 +3,7 @@ import type { ModelsStore, ModelsStoreEntry, ModelsStoreOperationOptions } from
import { getAgentDir } from "../config.ts";
import { raceWithAbortSignal } from "../utils/abort.ts";
import { getFileRevision, normalizePath } from "../utils/paths.ts";
import { stripBom } from "../utils/text.ts";
import { type AuthStorageBackend, FileAuthStorageBackend } from "./auth-storage.ts";
type StoredModels = Record<string, ModelsStoreEntry>;
@ -59,7 +60,7 @@ export class FileModelsStore implements ModelsStore {
}
private parse(content: string | undefined): StoredModels {
return content ? (JSON.parse(content) as StoredModels) : {};
return content ? (JSON.parse(stripBom(content)) as StoredModels) : {};
}
private updateReadState(readState: ModelsFileReadState, data: StoredModels, revision?: string): void {

View file

@ -32,6 +32,7 @@ import { CONFIG_DIR_NAME } from "../config.ts";
import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts";
import { type GitSource, parseGitUrl } from "../utils/git.ts";
import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts";
import { stripBom } from "../utils/text.ts";
import { isStdoutTakenOver } from "./output-guard.ts";
import { type PiManifest, readPiManifest } from "./pi-manifest.ts";
import type { PackageSource, SettingsManager } from "./settings-manager.ts";
@ -1479,7 +1480,7 @@ export class DefaultPackageManager implements PackageManager {
if (!existsSync(packageJsonPath)) return undefined;
try {
const content = readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content) as { version?: string };
const pkg = JSON.parse(stripBom(content)) as { version?: string };
return pkg.version;
} catch {
return undefined;
@ -1861,7 +1862,7 @@ export class DefaultPackageManager implements PackageManager {
if (!existsSync(packageJsonPath)) return false;
try {
const manifest = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { dependencies?: unknown };
const manifest = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))) as { dependencies?: unknown };
if (
!manifest.dependencies ||
typeof manifest.dependencies !== "object" ||

View file

@ -1,4 +1,5 @@
import { readFileSync } from "node:fs";
import { stripBom } from "../utils/text.ts";
export interface PiManifest {
extensions?: string[];
@ -15,7 +16,7 @@ function isObject(value: unknown): value is Record<string, unknown> {
export function readPiManifest(packageJsonPath: string): PiManifest | null {
try {
const pkg: unknown = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
const pkg: unknown = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8")));
if (!isObject(pkg) || !isObject(pkg.pi)) {
return null;
}

View file

@ -8,6 +8,7 @@ import type { ResourceDiagnostic } from "./diagnostics.ts";
export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
import { stripBom } from "../utils/text.ts";
import { createEventBus, type EventBus } from "./event-bus.ts";
import {
clearExtensionCache,
@ -57,7 +58,7 @@ function resolvePromptInput(input: string | undefined, description: string): str
if (existsSync(input)) {
try {
return readFileSync(input, "utf-8");
return stripBom(readFileSync(input, "utf-8"));
} catch (error) {
console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));
return input;
@ -78,7 +79,7 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
}
return {
path: filePath,
content: readFileSync(filePath, "utf-8"),
content: stripBom(readFileSync(filePath, "utf-8")),
};
} catch (error) {
console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));

View file

@ -213,6 +213,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
defaultProvider: settingsManager.getDefaultProvider(),
defaultModelId: settingsManager.getDefaultModel(),
defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(),
modelThinkingLevels: settingsManager.getAllModelThinkingLevels(),
modelRuntime,
});
model = result.model;
@ -232,7 +233,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
: (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL);
}
// Fall back to settings default
// Fall back to per-model override, then global default
if (thinkingLevel === undefined && model) {
const perModel = settingsManager.getModelThinkingLevel(model.provider, model.id);
if (perModel) {
thinkingLevel = perModel;
}
}
if (thinkingLevel === undefined) {
thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;
}

View file

@ -1388,13 +1388,14 @@ export class SessionManager {
if (branchFromId !== null && !this.byId.has(branchFromId)) {
throw new Error(`Entry ${branchFromId} not found`);
}
const fromId = this.leafId ?? "root";
this.leafId = branchFromId;
const entry: BranchSummaryEntry = {
type: "branch_summary",
id: generateId(this.byId),
parentId: branchFromId,
timestamp: new Date().toISOString(),
fromId: branchFromId ?? "root",
fromId,
summary,
details,
usage,

View file

@ -0,0 +1,25 @@
import type { AgentSessionRuntimeDiagnostic } from "./agent-session-services.ts";
import type { SettingsManager } from "./settings-manager.ts";
export function collectSettingsDiagnostics(settingsManager: SettingsManager): AgentSessionRuntimeDiagnostic[] {
return settingsManager.drainErrors().map(({ scope, path, error }) => ({
type: "warning",
message: path ? `Invalid settings file ${path}: ${error.message}` : `Invalid ${scope} settings: ${error.message}`,
}));
}
/**
* Remove duplicate type/message diagnostics while preserving their first occurrence.
* Startup and runtime settings managers can report the same file error.
*/
export function deduplicateDiagnostics(
diagnostics: readonly AgentSessionRuntimeDiagnostic[],
): AgentSessionRuntimeDiagnostic[] {
const seen = new Set<string>();
return diagnostics.filter((diagnostic) => {
const key = `${diagnostic.type}\0${diagnostic.message}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}

View file

@ -7,6 +7,7 @@ import { dirname, join } from "path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
import { stripBom } from "../utils/text.ts";
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts";
export interface CompactionSettings {
@ -92,6 +93,7 @@ export interface Settings {
defaultProvider?: string;
defaultModel?: string;
defaultThinkingLevel?: ThinkingLevel;
modelThinkingLevels?: Record<string, ThinkingLevel>; // per-model default thinking level overrides keyed by "provider/modelId"
transport?: TransportSetting; // default: "auto"
steeringMode?: "all" | "one-at-a-time";
followUpMode?: "all" | "one-at-a-time";
@ -190,9 +192,20 @@ export interface SettingsStorage {
export interface SettingsError {
scope: SettingsScope;
path?: string;
error: Error;
}
type SettingsPaths = Partial<Record<SettingsScope, string>>;
function toSettingsError(scope: SettingsScope, error: unknown, path?: string): SettingsError {
return {
scope,
...(path ? { path } : {}),
error: error instanceof Error ? error : new Error(String(error)),
};
}
export class FileSettingsStorage implements SettingsStorage {
private globalSettingsPath: string;
private projectSettingsPath: string;
@ -293,6 +306,7 @@ export class SettingsManager {
private projectSettingsLoadError: Error | null = null; // Track if project settings file had parse errors
private writeQueue: Promise<void> = Promise.resolve();
private errors: SettingsError[];
private settingsPaths: SettingsPaths;
private constructor(
storage: SettingsStorage,
@ -302,6 +316,7 @@ export class SettingsManager {
projectLoadError: Error | null = null,
initialErrors: SettingsError[] = [],
projectTrusted = true,
settingsPaths: SettingsPaths = {},
) {
this.storage = storage;
this.globalSettings = initialGlobal;
@ -310,6 +325,7 @@ export class SettingsManager {
this.globalSettingsLoadError = globalLoadError;
this.projectSettingsLoadError = projectLoadError;
this.errors = [...initialErrors];
this.settingsPaths = settingsPaths;
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
}
@ -319,21 +335,35 @@ export class SettingsManager {
agentDir: string = getAgentDir(),
options: SettingsManagerCreateOptions = {},
): SettingsManager {
const storage = new FileSettingsStorage(cwd, agentDir);
return SettingsManager.fromStorage(storage, options);
const resolvedCwd = resolvePath(cwd);
const resolvedAgentDir = resolvePath(agentDir);
const storage = new FileSettingsStorage(resolvedCwd, resolvedAgentDir);
return SettingsManager.fromStorageWithPaths(storage, options, {
global: join(resolvedAgentDir, "settings.json"),
project: join(resolvedCwd, CONFIG_DIR_NAME, "settings.json"),
});
}
/** Create a SettingsManager from an arbitrary storage backend */
static fromStorage(storage: SettingsStorage, options: SettingsManagerCreateOptions = {}): SettingsManager {
return SettingsManager.fromStorageWithPaths(storage, options);
}
/** Create a manager while retaining optional file paths for reported storage errors. */
private static fromStorageWithPaths(
storage: SettingsStorage,
options: SettingsManagerCreateOptions,
settingsPaths: SettingsPaths = {},
): SettingsManager {
const projectTrusted = options.projectTrusted ?? true;
const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global");
const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project", projectTrusted);
const initialErrors: SettingsError[] = [];
if (globalLoad.error) {
initialErrors.push({ scope: "global", error: globalLoad.error });
initialErrors.push(toSettingsError("global", globalLoad.error, settingsPaths.global));
}
if (projectLoad.error) {
initialErrors.push({ scope: "project", error: projectLoad.error });
initialErrors.push(toSettingsError("project", projectLoad.error, settingsPaths.project));
}
return new SettingsManager(
@ -344,6 +374,7 @@ export class SettingsManager {
projectLoad.error,
initialErrors,
projectTrusted,
settingsPaths,
);
}
@ -369,7 +400,7 @@ export class SettingsManager {
if (!content) {
return {};
}
const settings = JSON.parse(content);
const settings = JSON.parse(stripBom(content));
return SettingsManager.migrateSettings(settings);
}
@ -546,8 +577,7 @@ export class SettingsManager {
}
private recordError(scope: SettingsScope, error: unknown): void {
const normalizedError = error instanceof Error ? error : new Error(String(error));
this.errors.push({ scope, error: normalizedError });
this.errors.push(toSettingsError(scope, error, this.settingsPaths[scope]));
}
private clearModifiedScope(scope: SettingsScope): void {
@ -591,7 +621,7 @@ export class SettingsManager {
): void {
this.storage.withLock(scope, (current) => {
const currentFileSettings = current
? SettingsManager.migrateSettings(JSON.parse(current) as Record<string, unknown>)
? SettingsManager.migrateSettings(JSON.parse(stripBom(current)) as Record<string, unknown>)
: {};
const mergedSettings: Settings = { ...currentFileSettings };
for (const field of modifiedFields) {
@ -755,6 +785,33 @@ export class SettingsManager {
this.save();
}
getModelThinkingLevel(provider: string, modelId: string): ThinkingLevel | undefined {
return this.settings.modelThinkingLevels?.[`${provider}/${modelId}`];
}
getAllModelThinkingLevels(): Record<string, ThinkingLevel> {
return { ...(this.settings.modelThinkingLevels ?? {}) };
}
setModelThinkingLevel(provider: string, modelId: string, level: ThinkingLevel): void {
if (!this.globalSettings.modelThinkingLevels) {
this.globalSettings.modelThinkingLevels = {};
}
this.globalSettings.modelThinkingLevels[`${provider}/${modelId}`] = level;
this.markModified("modelThinkingLevels");
this.save();
}
removeModelThinkingLevel(provider: string, modelId: string): void {
if (!this.globalSettings.modelThinkingLevels) return;
delete this.globalSettings.modelThinkingLevels[`${provider}/${modelId}`];
if (Object.keys(this.globalSettings.modelThinkingLevels).length === 0) {
delete this.globalSettings.modelThinkingLevels;
}
this.markModified("modelThinkingLevels");
this.save();
}
getTransport(): TransportSetting {
return this.settings.transport ?? "auto";
}

View file

@ -18,7 +18,8 @@ export interface BuiltinSlashCommand {
export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
{ name: "settings", description: "Open settings menu" },
{ name: "model", description: "Select model (opens selector UI)", argumentHint: "<provider/model>" },
{ name: "model", description: "Select model (opens selector UI)", argumentHint: "[--default] <provider/model>" },
{ name: "thinking", description: "Set thinking level", argumentHint: "[--default] <level>" },
{ name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
{ name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
{ name: "import", description: "Import and resume a session from a JSONL file" },

View file

@ -5,6 +5,7 @@
import * as Diff from "diff";
import { constants } from "fs";
import { access, readFile } from "fs/promises";
import { splitBom } from "../../utils/text.ts";
import { resolveToCwd } from "./path-utils.ts";
export function detectLineEnding(content: string): "\r\n" | "\n" {
@ -243,11 +244,6 @@ export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResul
};
}
/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */
export function stripBom(content: string): { bom: string; text: string } {
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
}
function countOccurrences(content: string, oldText: string): number {
const fuzzyContent = normalizeForFuzzyMatch(content);
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
@ -535,7 +531,7 @@ export async function computeEditsDiff(
const rawContent = await readFile(absolutePath, "utf-8");
// Strip BOM before matching (LLM won't include invisible BOM in oldText)
const { text: content } = stripBom(rawContent);
const { text: content } = splitBom(rawContent);
const normalizedContent = normalizeToLF(content);
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);

View file

@ -5,6 +5,7 @@ import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile }
import { type Static, Type } from "typebox";
import { renderDiff } from "../../modes/interactive/components/diff.ts";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import { splitBom } from "../../utils/text.ts";
import { getExperimentalToolSampling } from "../experimental.ts";
import type { ToolDefinition } from "../extensions/types.ts";
import {
@ -18,7 +19,6 @@ import {
generateUnifiedPatch,
normalizeToLF,
restoreLineEndings,
stripBom,
} from "./edit-diff.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToCwd } from "./path-utils.ts";
@ -361,7 +361,7 @@ export function createEditToolDefinition(
throwIfAborted();
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
const { bom, text: content } = stripBom(rawContent);
const { bom, text: content } = splitBom(rawContent);
const originalEnding = detectLineEnding(content);
const normalizedContent = normalizeToLF(content);
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);

View file

@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME } from "../config.ts";
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
import { stripBom } from "../utils/text.ts";
export type ProjectTrustDecision = boolean | null;
@ -101,7 +102,7 @@ function readTrustFile(path: string): TrustFile {
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(path, "utf-8"));
parsed = JSON.parse(stripBom(readFileSync(path, "utf-8")));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to read trust store ${path}: ${message}`);

View file

@ -28,7 +28,6 @@ export { readStoredCredential } from "./core/auth-storage.ts";
export {
type BranchPreparation,
type BranchSummaryResult,
type CacheFriendlySummaryOptions,
type CollectEntriesResult,
type CompactionResult,
type CutPointResult,

View file

@ -60,6 +60,7 @@ import {
type SessionCwdIssue,
} from "./core/session-cwd.ts";
import { assertValidSessionId, SessionManager } from "./core/session-manager.ts";
import { collectSettingsDiagnostics, deduplicateDiagnostics } from "./core/settings-diagnostics.ts";
import { SettingsManager } from "./core/settings-manager.ts";
import { printTimings, resetTimings, time } from "./core/timings.ts";
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts";
@ -98,16 +99,6 @@ async function readPipedStdin(): Promise<string | undefined> {
});
}
function collectSettingsDiagnostics(
settingsManager: SettingsManager,
context: string,
): AgentSessionRuntimeDiagnostic[] {
return settingsManager.drainErrors().map(({ scope, error }) => ({
type: "warning",
message: `(${context}, ${scope} settings) ${error.message}`,
}));
}
function reportDiagnostics(diagnostics: readonly AgentSessionRuntimeDiagnostic[]): void {
for (const diagnostic of diagnostics) {
const color = diagnostic.type === "error" ? chalk.red : diagnostic.type === "warning" ? chalk.yellow : chalk.dim;
@ -747,7 +738,7 @@ export async function main(args: string[], options?: MainOptions) {
time("runMigrations");
const startupSettingsManager = SettingsManager.create(cwd, agentDir);
reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup"));
const startupSettingsDiagnostics = collectSettingsDiagnostics(startupSettingsManager);
// Experimental first-time setup: theme choice and analytics opt-in.
// Runs before any runtime services are created so the chosen settings apply everywhere.
@ -875,7 +866,7 @@ export async function main(args: string[], options?: MainOptions) {
const diagnostics: AgentSessionRuntimeDiagnostic[] = [
...projectTrustDiagnostics,
...services.diagnostics,
...collectSettingsDiagnostics(settingsManager, "runtime creation"),
...collectSettingsDiagnostics(settingsManager),
...resourceLoader.getExtensions().errors.map(({ path, error }) => ({
type: "error" as const,
message: `Failed to load extension "${path}": ${error}`,
@ -947,6 +938,7 @@ export async function main(args: string[], options?: MainOptions) {
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
if (parsed.help) {
reportDiagnostics(startupSettingsDiagnostics);
const extensionFlags = resourceLoader
.getExtensions()
.extensions.flatMap((extension) => Array.from(extension.flags.values()));
@ -955,6 +947,7 @@ export async function main(args: string[], options?: MainOptions) {
}
if (parsed.listModels !== undefined) {
reportDiagnostics(startupSettingsDiagnostics);
const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined;
await listModels(modelRuntime, searchPattern, AbortSignal.timeout(15_000));
process.exit(0);
@ -985,8 +978,12 @@ export async function main(args: string[], options?: MainOptions) {
}
time("resolveModelScope");
reportDiagnostics(runtime.diagnostics);
if (runtime.diagnostics.some((diagnostic) => diagnostic.type === "error")) {
const startupDiagnostics = deduplicateDiagnostics([...startupSettingsDiagnostics, ...runtime.diagnostics]);
const hasRuntimeErrors = runtime.diagnostics.some((diagnostic) => diagnostic.type === "error");
if (appMode !== "interactive" || hasRuntimeErrors) {
reportDiagnostics(startupDiagnostics);
}
if (hasRuntimeErrors) {
if (runtime.diagnostics.some((diagnostic) => diagnostic.message.includes("Failed to load extension"))) {
console.error(chalk.yellow(EXTENSION_LOAD_FAILURE_HINT));
}
@ -1021,6 +1018,7 @@ export async function main(args: string[], options?: MainOptions) {
} else if (appMode === "interactive") {
const interactiveMode = new InteractiveMode(runtime, {
migratedProviders,
startupDiagnostics,
modelFallbackMessage,
autoTrustOnReloadCwd,
initialMessage,

View file

@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, w
import { dirname, join } from "path";
import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.ts";
import { migrateKeybindingsConfig } from "./core/keybindings.ts";
import { stripBom } from "./utils/text.ts";
const MIGRATION_GUIDE_URL =
"https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration";
@ -33,7 +34,7 @@ export function migrateAuthToAuthJson(): string[] {
// Migrate oauth.json
if (existsSync(oauthPath)) {
try {
const oauth = JSON.parse(readFileSync(oauthPath, "utf-8"));
const oauth = JSON.parse(stripBom(readFileSync(oauthPath, "utf-8")));
for (const [provider, cred] of Object.entries(oauth)) {
migrated[provider] = { type: "oauth", ...(cred as object) };
providers.push(provider);
@ -48,7 +49,7 @@ export function migrateAuthToAuthJson(): string[] {
if (existsSync(settingsPath)) {
try {
const content = readFileSync(settingsPath, "utf-8");
const settings = JSON.parse(content);
const settings = JSON.parse(stripBom(content));
if (settings.apiKeys && typeof settings.apiKeys === "object") {
for (const [provider, key] of Object.entries(settings.apiKeys)) {
if (!migrated[provider] && typeof key === "string") {
@ -159,7 +160,7 @@ function migrateKeybindingsConfigFile(): void {
if (!existsSync(configPath)) return;
try {
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as unknown;
const parsed = JSON.parse(stripBom(readFileSync(configPath, "utf-8"))) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return;
}

View file

@ -5,12 +5,12 @@ import {
fuzzyFilter,
getKeybindings,
Input,
matchesKey,
Spacer,
Text,
type TUI,
} from "@earendil-works/pi-tui";
import type { ModelRuntime } from "../../../core/model-runtime.ts";
import type { SettingsManager } from "../../../core/settings-manager.ts";
import { refreshModelCatalogs } from "../model-catalog-refresh.ts";
import { getModelSelectorSearchText } from "../model-search.ts";
import { theme } from "../theme/theme.ts";
@ -52,9 +52,9 @@ export class ModelSelectorComponent extends Container implements Focusable {
private filteredModels: ModelItem[] = [];
private selectedIndex: number = 0;
private currentModel?: Model<any>;
private settingsManager: SettingsManager;
private modelRuntime: ModelRuntime;
private onSelectCallback: (model: Model<any>) => void;
private onSelectAsDefaultCallback?: (model: Model<any>) => void;
private onCancelCallback: () => void;
private errorMessage?: string;
private refreshStatusMessage = "Refreshing model catalogs…";
@ -71,22 +71,22 @@ export class ModelSelectorComponent extends Container implements Focusable {
constructor(
tui: TUI,
currentModel: Model<any> | undefined,
settingsManager: SettingsManager,
modelRuntime: ModelRuntime,
scopedModels: ReadonlyArray<ScopedModelItem>,
onSelect: (model: Model<any>) => void,
onCancel: () => void,
initialSearchInput?: string,
onSelectAsDefault?: (model: Model<any>) => void,
) {
super();
this.tui = tui;
this.currentModel = currentModel;
this.settingsManager = settingsManager;
this.modelRuntime = modelRuntime;
this.scopedModels = scopedModels;
this.scope = scopedModels.length > 0 ? "scoped" : "all";
this.onSelectCallback = onSelect;
this.onSelectAsDefaultCallback = onSelectAsDefault;
this.onCancelCallback = onCancel;
// Add top border
@ -126,6 +126,13 @@ export class ModelSelectorComponent extends Container implements Focusable {
this.addChild(new Spacer(1));
// Hint
if (this.onSelectAsDefaultCallback) {
this.addChild(
new Text(theme.fg("dim", " Enter to select \u00b7 Ctrl+S to set as default \u00b7 Esc to cancel"), 0, 0),
);
}
// Add bottom border
this.addChild(new DynamicBorder());
@ -354,6 +361,14 @@ export class ModelSelectorComponent extends Container implements Focusable {
this.dispose();
this.onCancelCallback();
}
// Ctrl+S — select and save as default
else if (matchesKey(keyData, "ctrl+s") && this.onSelectAsDefaultCallback) {
const selectedModel = this.filteredModels[this.selectedIndex];
if (selectedModel) {
this.dispose();
this.onSelectAsDefaultCallback(selectedModel.model);
}
}
// Pass everything else to search input
else {
this.searchInput.handleInput(keyData);
@ -363,8 +378,6 @@ export class ModelSelectorComponent extends Container implements Focusable {
private handleSelect(model: Model<any>): void {
this.dispose();
// Save as new default
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
this.onSelectCallback(model);
}

View file

@ -1,13 +1,11 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Transport } from "@earendil-works/pi-ai";
import { getSupportedThinkingLevels, type Model, type Transport } from "@earendil-works/pi-ai";
import {
type Component,
Container,
getCapabilities,
type ScrollViewScrollbar,
type SelectItem,
SelectList,
type SelectListLayoutOptions,
type SettingItem,
SettingsList,
Spacer,
@ -21,20 +19,15 @@ import type {
TuiMode,
WarningSettings,
} from "../../../core/settings-manager.ts";
import {
getSelectListTheme,
getSettingsListTheme,
parseAutoThemeSetting,
type TerminalTheme,
theme,
} from "../theme/theme.ts";
import { getSettingsListTheme, parseAutoThemeSetting, type TerminalTheme, theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyDisplayText } from "./keybinding-hints.ts";
import { SelectSubmenu, SteppedSubmenu, type SteppedSubmenuStep } from "./settings-submenu.ts";
const SETTINGS_SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 32,
};
const NO_DEFAULT_MODEL_VALUE = "__none__";
const NO_DEFAULT_MODEL_LABEL = "not set";
const MODEL_PICKER_LAYOUT = { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 46 };
const THINKING_DESCRIPTIONS: Record<ThinkingLevel, string> = {
off: "No reasoning",
@ -58,6 +51,8 @@ const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map(
export interface SettingsConfig {
autoCompact: boolean;
defaultModel: string;
availableDefaultModels: readonly Model<any>[];
showImages: boolean;
imageWidthCells: number;
autoResizeImages: boolean;
@ -69,6 +64,7 @@ export interface SettingsConfig {
httpIdleTimeoutMs: number;
thinkingLevel: ThinkingLevel;
availableThinkingLevels: ThinkingLevel[];
modelThinkingLevels: Record<string, ThinkingLevel>;
currentTheme: string;
terminalTheme: TerminalTheme;
availableThemes: string[];
@ -95,6 +91,7 @@ export interface SettingsConfig {
export interface SettingsCallbacks {
onAutoCompactChange: (enabled: boolean) => void;
onDefaultModelChange: (model: Model<any>) => Promise<void>;
onShowImagesChange: (enabled: boolean) => void;
onImageWidthCellsChange: (width: number) => void;
onAutoResizeImagesChange: (enabled: boolean) => void;
@ -105,6 +102,8 @@ export interface SettingsCallbacks {
onTransportChange: (transport: Transport) => void;
onHttpIdleTimeoutMsChange: (timeoutMs: number) => void;
onThinkingLevelChange: (level: ThinkingLevel) => void;
onModelThinkingLevelChange: (provider: string, modelId: string, level: ThinkingLevel) => void;
onModelThinkingLevelRemove: (provider: string, modelId: string) => void;
onThemeChange: (theme: string) => void;
onThemePreview?: (theme: string) => void;
onHideThinkingBlockChange: (hidden: boolean) => void;
@ -174,68 +173,52 @@ class WarningSettingsSubmenu extends Container {
}
}
class SelectSubmenu extends Container {
private selectList: SelectList;
const CLEAR_OVERRIDE_VALUE = "__clear__";
constructor(
title: string,
description: string,
options: SelectItem[],
currentValue: string,
onSelect: (value: string) => void,
onCancel: () => void,
onSelectionChange?: (value: string) => void,
) {
super();
function modelSettingKey(model: Model<any>): string {
return `${model.provider}/${model.id}`;
}
// Title
this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0));
function modelDisplayLabel(model: Model<any>): string {
return `${model.id} [${model.provider}]`;
}
// Description
if (description) {
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("muted", description), 0, 0));
}
function defaultModelDisplayValue(
key: string,
model: Model<any> | undefined,
overrides: Record<string, ThinkingLevel>,
): string {
const label = model ? modelDisplayLabel(model) : key;
const override = overrides[key];
return override ? `${label} \u00b7 ${override}` : label;
}
// Spacer
this.addChild(new Spacer(1));
function modelThinkingOverridesSummary(overrides: Record<string, ThinkingLevel>): string {
const count = Object.keys(overrides).length;
if (count === 0) return "none";
return `${count} configured`;
}
// Select list
this.selectList = new SelectList(
options,
Math.min(options.length, 10),
getSelectListTheme(),
SETTINGS_SUBMENU_SELECT_LIST_LAYOUT,
);
function modelItemLabel(model: Model<any>): string {
return `${model.id} ${theme.fg("muted", `[${model.provider}]`)}`;
}
// Pre-select current value
const currentIndex = options.findIndex((o) => o.value === currentValue);
if (currentIndex !== -1) {
this.selectList.setSelectedIndex(currentIndex);
}
this.selectList.onSelect = (item) => {
onSelect(item.value);
};
this.selectList.onCancel = onCancel;
if (onSelectionChange) {
this.selectList.onSelectionChange = (item) => {
onSelectionChange(item.value);
function defaultModelItems(models: readonly Model<any>[], overrides?: Record<string, ThinkingLevel>): SelectItem[] {
return [...models]
.sort((a, b) => {
const providerCompare = a.provider.localeCompare(b.provider);
if (providerCompare !== 0) return providerCompare;
return (a.name || a.id).localeCompare(b.name || b.id);
})
.map((model) => {
const key = modelSettingKey(model);
const override = overrides?.[key];
return {
value: key,
label: modelItemLabel(model),
description: override ?? undefined,
};
}
this.addChild(this.selectList);
// Hint
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0));
}
handleInput(data: string): void {
this.selectList.handleInput(data);
}
});
}
function themeItems(availableThemes: string[]): SelectItem[] {
@ -492,7 +475,16 @@ export class SettingsSelectorComponent extends Container {
const supportsImages = getCapabilities().images;
const followUpKey = keyDisplayText("app.message.followUp");
const cycleThinkingKey = keyDisplayText("app.thinking.cycle");
let currentWarnings = { ...config.warnings };
const currentModelThinkingLevels = { ...config.modelThinkingLevels };
let lastSelectedDefaultModel: Model<any> | undefined;
const defaultModelByValue = new Map(
config.availableDefaultModels.map((model) => [modelSettingKey(model), model]),
);
let currentDefaultModelKey: string | undefined = defaultModelByValue.has(config.defaultModel)
? config.defaultModel
: undefined;
const items: SettingItem[] = [
{
@ -524,6 +516,55 @@ export class SettingsSelectorComponent extends Container {
currentValue: config.transport,
values: ["sse", "websocket", "websocket-cached", "auto"],
},
{
id: "default-model",
label: "Default model",
description: "Startup model for new sessions",
currentValue: defaultModelDisplayValue(
config.defaultModel,
defaultModelByValue.get(config.defaultModel),
currentModelThinkingLevels,
),
submenu: (currentValue, done) => {
const fresh = defaultModelItems(config.availableDefaultModels, currentModelThinkingLevels);
const options =
fresh.length > 0
? fresh
: [
{
value: NO_DEFAULT_MODEL_VALUE,
label: "No models available",
description: "Log in to a provider or configure an API key first",
},
];
return new SelectSubmenu(
"Default Model",
"Select the model to use when starting new sessions",
options,
currentValue === NO_DEFAULT_MODEL_LABEL ? NO_DEFAULT_MODEL_VALUE : currentValue,
(value) => {
const model = defaultModelByValue.get(value);
if (!model) {
done();
return;
}
void callbacks.onDefaultModelChange(model).then(
() => {
lastSelectedDefaultModel = model;
currentDefaultModelKey = value;
done(defaultModelDisplayValue(value, model, currentModelThinkingLevels), {
navigateTo: "model-thinking",
});
},
() => done(),
);
},
() => done(),
undefined,
{ searchable: true, layout: MODEL_PICKER_LAYOUT },
);
},
},
{
id: "http-idle-timeout",
label: "HTTP idle timeout",
@ -612,13 +653,13 @@ export class SettingsSelectorComponent extends Container {
},
{
id: "thinking",
label: "Thinking level",
description: "Reasoning depth for thinking-capable models",
label: "Default thinking level",
description: `Startup reasoning depth for thinking-capable models. ${cycleThinkingKey} cycles in-session.`,
currentValue: config.thinkingLevel,
submenu: (currentValue, done) =>
new SelectSubmenu(
"Thinking Level",
"Select reasoning depth for thinking-capable models",
"Default Thinking Level",
"Select the reasoning depth to use when starting new sessions",
config.availableThinkingLevels.map((level) => ({
value: level,
label: level,
@ -632,6 +673,119 @@ export class SettingsSelectorComponent extends Container {
() => done(),
),
},
{
id: "model-thinking",
label: "Default thinking level per model",
description: `Override the default thinking level for specific models. ${cycleThinkingKey} cycles in-session.`,
currentValue: modelThinkingOverridesSummary(currentModelThinkingLevels),
submenu: (_currentValue, done) => {
const preselected = lastSelectedDefaultModel;
lastSelectedDefaultModel = undefined;
const steps: SteppedSubmenuStep[] = [
{
key: "model",
title: "Per-Model Thinking Level",
description: "Select a model to configure",
options: () => {
const sorted = [...config.availableDefaultModels].sort((a, b) => {
const aKey = modelSettingKey(a);
const bKey = modelSettingKey(b);
if (aKey === currentDefaultModelKey) return -1;
if (bKey === currentDefaultModelKey) return 1;
const pc = a.provider.localeCompare(b.provider);
if (pc !== 0) return pc;
return (a.name || a.id).localeCompare(b.name || b.id);
});
const items: SelectItem[] = sorted.map((model) => {
const key = modelSettingKey(model);
const override = currentModelThinkingLevels[key];
return {
value: key,
label: modelItemLabel(model),
description: override ?? undefined,
};
});
if (items.length === 0) {
items.push({
value: "__none__",
label: "No models available",
description: "Log in to a provider or configure an API key first",
});
}
return items;
},
preselect: () => currentDefaultModelKey,
searchable: true,
layout: MODEL_PICKER_LAYOUT,
},
{
key: "level",
title: (ctx) => {
const m = defaultModelByValue.get(ctx.model);
return `Thinking Level for ${m ? modelDisplayLabel(m) : ctx.model}`;
},
description: "Select default thinking level for this model",
options: (ctx) => {
const model = defaultModelByValue.get(ctx.model);
if (!model) return [];
const levels = (
model.reasoning ? getSupportedThinkingLevels(model) : ["off"]
) as ThinkingLevel[];
const items: SelectItem[] = levels.map((level) => ({
value: level,
label: level,
description: THINKING_DESCRIPTIONS[level],
}));
if (currentModelThinkingLevels[ctx.model] !== undefined) {
items.push({
value: CLEAR_OVERRIDE_VALUE,
label: "(clear override)",
description: `Revert to global default (${config.thinkingLevel})`,
});
}
return items;
},
preselect: (ctx) => currentModelThinkingLevels[ctx.model],
},
];
const summary = () => modelThinkingOverridesSummary(currentModelThinkingLevels);
return new SteppedSubmenu(
steps,
(selections) => {
const model = defaultModelByValue.get(selections.model);
if (!model) return;
if (selections.level === CLEAR_OVERRIDE_VALUE) {
callbacks.onModelThinkingLevelRemove(model.provider, model.id);
delete currentModelThinkingLevels[selections.model];
} else {
callbacks.onModelThinkingLevelChange(
model.provider,
model.id,
selections.level as ThinkingLevel,
);
currentModelThinkingLevels[selections.model] = selections.level as ThinkingLevel;
}
},
() => {
this.settingsList.updateValue(
"default-model",
defaultModelDisplayValue(
currentDefaultModelKey ?? config.defaultModel,
defaultModelByValue.get(currentDefaultModelKey ?? config.defaultModel),
currentModelThinkingLevels,
),
);
done(summary());
},
preselected
? { startAtStep: 1, initialContext: { model: modelSettingKey(preselected) } }
: { loop: true },
);
},
},
{
id: "tui-mode",
label: "TUI mode",

View file

@ -0,0 +1,258 @@
import {
type Component,
Container,
fuzzyFilter,
getKeybindings,
Input,
type SelectItem,
SelectList,
type SelectListLayoutOptions,
Spacer,
Text,
} from "@earendil-works/pi-tui";
import { getSelectListTheme, theme } from "../theme/theme.ts";
const SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 32,
};
export interface SelectSubmenuOptions {
/** Enable type-to-search fuzzy filtering. */
searchable?: boolean;
/** Override the select list layout (column widths). */
layout?: SelectListLayoutOptions;
}
/**
* Single-step submenu that shows a titled select list.
* With `searchable: true`, typing filters the list using fuzzy matching.
*/
export class SelectSubmenu extends Container {
private selectList: SelectList;
private listChildIndex: number;
private allOptions: SelectItem[];
private listLayout: SelectListLayoutOptions;
private searchInput: Input | undefined;
private onSelectCb: (value: string) => void;
private onCancelCb: () => void;
private onSelectionChangeCb?: (value: string) => void;
constructor(
title: string,
description: string,
options: SelectItem[],
currentValue: string,
onSelect: (value: string) => void,
onCancel: () => void,
onSelectionChange?: (value: string) => void,
submenuOptions?: SelectSubmenuOptions,
) {
super();
this.allOptions = options;
this.listLayout = submenuOptions?.layout ?? SUBMENU_SELECT_LIST_LAYOUT;
this.onSelectCb = onSelect;
this.onCancelCb = onCancel;
this.onSelectionChangeCb = onSelectionChange;
// Title
this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0));
// Description
if (description) {
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("muted", description), 0, 0));
}
// Search input
if (submenuOptions?.searchable) {
this.addChild(new Spacer(1));
this.searchInput = new Input();
this.searchInput.onSubmit = () => {
this.selectList.handleInput("\r");
};
this.addChild(this.searchInput);
}
// Spacer
this.addChild(new Spacer(1));
// Select list
this.selectList = this.buildSelectList(options, currentValue);
this.listChildIndex = this.children.length;
this.addChild(this.selectList);
// Hint
this.addChild(new Spacer(1));
const hint = submenuOptions?.searchable
? " Type to filter \u00b7 Enter to select \u00b7 Esc to go back"
: " Enter to select \u00b7 Esc to go back";
this.addChild(new Text(theme.fg("dim", hint), 0, 0));
}
private buildSelectList(options: SelectItem[], preselect: string): SelectList {
const list = new SelectList(options, Math.min(options.length, 10), getSelectListTheme(), this.listLayout);
const idx = options.findIndex((o) => o.value === preselect);
if (idx !== -1) list.setSelectedIndex(idx);
list.onSelect = (item) => this.onSelectCb(item.value);
list.onCancel = this.onCancelCb;
if (this.onSelectionChangeCb) {
const cb = this.onSelectionChangeCb;
list.onSelectionChange = (item) => cb(item.value);
}
return list;
}
private applyFilter(query: string): void {
const filtered = query
? fuzzyFilter(this.allOptions, query, (item) => `${item.label} ${item.description ?? ""}`)
: this.allOptions;
const newList = this.buildSelectList(filtered, "");
this.children[this.listChildIndex] = newList;
this.selectList = newList;
}
handleInput(data: string): void {
if (this.searchInput) {
const kb = getKeybindings();
const isNav =
kb.matches(data, "tui.select.up") ||
kb.matches(data, "tui.select.down") ||
kb.matches(data, "tui.select.confirm") ||
kb.matches(data, "tui.select.cancel");
if (isNav) {
this.selectList.handleInput(data);
} else {
this.searchInput.handleInput(data);
this.applyFilter(this.searchInput.getValue());
}
} else {
this.selectList.handleInput(data);
}
}
}
// ============================================================================
// SteppedSubmenu — reusable multi-step selector
// ============================================================================
/** One step in a {@link SteppedSubmenu}. */
export interface SteppedSubmenuStep {
/** Unique key \u2014 the selected value is stored in the result context under this key. */
key: string;
/** Title shown at the top of the step. Receives prior selections. */
title: string | ((context: Record<string, string>) => string);
/** Description shown below the title. Receives prior selections. */
description: string | ((context: Record<string, string>) => string);
/** Build the option list for this step. Called fresh each time the step is shown. */
options: (context: Record<string, string>) => SelectItem[];
/** Optionally pre-select a value when entering this step. */
preselect?: (context: Record<string, string>) => string | undefined;
/** Enable type-to-search fuzzy filtering for this step. */
searchable?: boolean;
/** Override the select list layout (column widths) for this step. */
layout?: SelectListLayoutOptions;
}
interface SteppedSubmenuOptions {
/** Start at this step index (0-based), skipping earlier steps. Requires initialContext for skipped keys. */
startAtStep?: number;
/** Pre-fill selections for skipped steps. */
initialContext?: Record<string, string>;
/** After completing the last step, loop back to step 0 instead of closing. */
loop?: boolean;
}
/**
* Generic N-step submenu built on top of {@link SelectSubmenu}.
*
* Each step's options can depend on prior selections via the shared context.
* Esc goes back one step; Esc at step 0 cancels.
* With `loop: true`, completing the final step invokes `onComplete` then returns to step 0.
*/
export class SteppedSubmenu extends Container {
private readonly steps: SteppedSubmenuStep[];
private readonly onComplete: (context: Record<string, string>) => void;
private readonly onCancel: () => void;
private readonly opts: SteppedSubmenuOptions;
private activeComponent: Component;
private context: Record<string, string>;
constructor(
steps: SteppedSubmenuStep[],
onComplete: (context: Record<string, string>) => void,
onCancel: () => void,
opts: SteppedSubmenuOptions = {},
) {
super();
this.steps = steps;
this.onComplete = onComplete;
this.onCancel = onCancel;
this.opts = opts;
this.context = { ...(opts.initialContext ?? {}) };
this.activeComponent = this.buildStep(opts.startAtStep ?? 0);
}
private buildStep(stepIndex: number): Component {
const step = this.steps[stepIndex];
const total = this.steps.length;
const stepLabel = total > 1 ? `Step ${stepIndex + 1}/${total} \u00b7 ` : "";
const title = typeof step.title === "function" ? step.title(this.context) : step.title;
const desc = typeof step.description === "function" ? step.description(this.context) : step.description;
const items = step.options(this.context);
const preselect = step.preselect?.(this.context) ?? "";
return new SelectSubmenu(
title,
`${stepLabel}${desc}`,
items,
preselect,
(value) => {
this.context[step.key] = value;
if (stepIndex < total - 1) {
// Advance to next step
this.activeComponent = this.buildStep(stepIndex + 1);
} else {
// Final step \u2014 deliver result
this.onComplete({ ...this.context });
if (this.opts.loop) {
this.context = {};
this.activeComponent = this.buildStep(0);
} else {
this.onCancel();
}
}
},
() => {
if (stepIndex > 0) {
delete this.context[step.key];
this.activeComponent = this.buildStep(stepIndex - 1);
} else {
this.onCancel();
}
},
undefined,
step.searchable || step.layout ? { searchable: step.searchable, layout: step.layout } : undefined,
);
}
render(width: number): string[] {
return this.activeComponent.render(width);
}
handleInput(data: string): void {
this.activeComponent.handleInput?.(data);
}
invalidate(): void {
this.activeComponent.invalidate?.();
}
}

View file

@ -1,7 +1,17 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui";
import { getSelectListTheme } from "../theme/theme.ts";
import {
Container,
type Focusable,
matchesKey,
type SelectItem,
SelectList,
type SelectListLayoutOptions,
Spacer,
Text,
} from "@earendil-works/pi-tui";
import { getSelectListTheme, theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyDisplayText } from "./keybinding-hints.ts";
const THINKING_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,
@ -21,16 +31,28 @@ const LEVEL_DESCRIPTIONS: Record<ThinkingLevel, string> = {
/**
* Component that renders a thinking level selector with borders
*/
export class ThinkingSelectorComponent extends Container {
export class ThinkingSelectorComponent extends Container implements Focusable {
private selectList: SelectList;
private onSelectAsDefault?: (level: ThinkingLevel) => void;
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
}
constructor(
currentLevel: ThinkingLevel,
availableLevels: ThinkingLevel[],
onSelect: (level: ThinkingLevel) => void,
onCancel: () => void,
onSelectAsDefault?: (level: ThinkingLevel) => void,
) {
super();
this.onSelectAsDefault = onSelectAsDefault;
const thinkingLevels: SelectItem[] = availableLevels.map((level) => ({
value: level,
@ -40,6 +62,11 @@ export class ThinkingSelectorComponent extends Container {
// Add top border
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(new Text("Thinking Level", 0, 0));
this.addChild(new Spacer(1));
this.addChild(new Text(`${keyDisplayText("app.thinking.cycle")} cycles thinking levels in-session`, 0, 0));
this.addChild(new Spacer(1));
// Create selector
this.selectList = new SelectList(
@ -64,11 +91,23 @@ export class ThinkingSelectorComponent extends Container {
};
this.addChild(this.selectList);
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("dim", " Enter to select · Ctrl+S to set as default · Esc to cancel"), 0, 0));
// Add bottom border
this.addChild(new DynamicBorder());
}
handleInput(keyData: string): void {
if (matchesKey(keyData, "ctrl+s") && this.onSelectAsDefault) {
const item = this.selectList.getSelectedItem();
if (item) this.onSelectAsDefault(item.value as ThinkingLevel);
return;
}
this.selectList.handleInput(keyData);
}
getSelectList(): SelectList {
return this.selectList;
}

View file

@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { stripBom } from "../../utils/text.ts";
export interface ExternalEditorOptions {
command: string;
@ -34,7 +35,7 @@ export async function editInExternalEditor(options: ExternalEditorOptions): Prom
return { status: "failed" };
}
return { status: "complete", content: readFileSync(filePath, "utf-8").replace(/\n$/, "") };
return { status: "complete", content: stripBom(readFileSync(filePath, "utf-8")).replace(/\n$/, "") };
} finally {
try {
rmSync(directory, { recursive: true, force: true });

View file

@ -7,7 +7,7 @@ import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
import type { AssistantMessage, ImageContent, Message, Model, Usage } from "@earendil-works/pi-ai/compat";
import type {
@ -58,6 +58,7 @@ import {
} from "../../config.ts";
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts";
import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts";
import type { AgentSessionRuntimeDiagnostic } from "../../core/agent-session-services.ts";
import {
CACHE_TTL_MS,
type CacheMiss,
@ -65,6 +66,7 @@ import {
computeCacheWaste,
detectCacheMiss,
} from "../../core/cache-stats.ts";
import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "../../core/defaults.ts";
import type {
AutocompleteProviderFactory,
EditorFactory,
@ -146,6 +148,7 @@ import {
type StatusIndicator,
WorkingStatusIndicator,
} from "./components/status-indicator.ts";
import { ThinkingSelectorComponent } from "./components/thinking-selector.ts";
import { ToolExecutionComponent } from "./components/tool-execution.ts";
import { TreeSelectorComponent } from "./components/tree-selector.ts";
import { TrustSelectorComponent } from "./components/trust-selector.ts";
@ -230,6 +233,44 @@ function isDeadTerminalError(error: unknown): boolean {
return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
}
export interface DefaultFlagArgs {
searchTerm?: string;
persist: boolean;
error?: string;
}
export function parseDefaultFlagArgs(commandName: string, args: string | undefined): DefaultFlagArgs {
if (!args?.trim()) return { persist: false };
let persist = false;
const searchTerms: string[] = [];
for (const token of args.trim().split(/\s+/u)) {
if (token === "--default") {
persist = true;
continue;
}
if (token.startsWith("--default=")) {
persist = true;
const value = token.slice("--default=".length);
if (value) searchTerms.push(value);
continue;
}
if (token.startsWith("--")) {
return {
persist,
searchTerm: searchTerms.join(" ") || undefined,
error: `Unknown /${commandName} option "${token}". Supported option: --default.`,
};
}
searchTerms.push(token);
}
return { persist, searchTerm: searchTerms.join(" ") || undefined };
}
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings.";
@ -332,6 +373,8 @@ function formatLoginProviderCompletionDescription(provider: LoginProviderComplet
export interface InteractiveModeOptions {
/** Providers that were migrated to auth.json (shows warning) */
migratedProviders?: string[];
/** Diagnostics collected before the interactive TUI was initialized. */
startupDiagnostics?: AgentSessionRuntimeDiagnostic[];
/** Warning message if session model couldn't be restored */
modelFallbackMessage?: string;
/** Cwd to trust after reload if it gained a .pi directory during this implicitly trusted session. */
@ -678,6 +721,16 @@ export class InteractiveMode {
const modelCommand = slashCommands.find((command) => command.name === "model");
if (modelCommand) {
modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {
const trimmedPrefix = prefix.trimStart();
if (trimmedPrefix.startsWith("--") && !trimmedPrefix.includes(" ")) {
return "--default".startsWith(trimmedPrefix)
? [{ value: "--default ", label: "--default", description: "Set as startup default" }]
: null;
}
const parsed = parseDefaultFlagArgs("model", prefix);
if (parsed.error) return null;
const models =
this.session.scopedModels.length > 0
? this.session.scopedModels.map((s) => s.model)
@ -693,14 +746,41 @@ export class InteractiveMode {
label: `${m.provider}/${m.id}`,
}));
return createFuzzyAutocompleteItems(items, prefix, getModelSearchText, (item) => ({
value: item.label,
const searchPrefix = parsed.persist ? (parsed.searchTerm ?? "") : prefix;
return createFuzzyAutocompleteItems(items, searchPrefix, getModelSearchText, (item) => ({
value: parsed.persist ? `--default ${item.label}` : item.label,
label: item.id,
description: item.provider,
description: parsed.persist ? `${item.provider} · set as startup default` : item.provider,
}));
};
}
const thinkingCommand = slashCommands.find((command) => command.name === "thinking");
if (thinkingCommand) {
thinkingCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {
const trimmedPrefix = prefix.trimStart();
if (trimmedPrefix.startsWith("--") && !trimmedPrefix.includes(" ")) {
return "--default".startsWith(trimmedPrefix)
? [{ value: "--default ", label: "--default", description: "Set as startup default" }]
: null;
}
const parsed = parseDefaultFlagArgs("thinking", prefix);
if (parsed.error) return null;
const searchPrefix = parsed.persist ? (parsed.searchTerm ?? "") : prefix;
return createFuzzyAutocompleteItems(
this.session.getAvailableThinkingLevels(),
searchPrefix,
(level) => level,
(level) => ({
value: parsed.persist ? `--default ${level}` : level,
label: level,
description: parsed.persist ? "set as startup default" : undefined,
}),
);
};
}
const loginCommand = slashCommands.find((command) => command.name === "login");
if (loginCommand) {
loginCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {
@ -1090,7 +1170,24 @@ export class InteractiveMode {
});
// Show startup warnings
const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options;
const {
migratedProviders,
startupDiagnostics,
modelFallbackMessage,
initialMessage,
initialImages,
initialMessages,
} = this.options;
for (const diagnostic of startupDiagnostics ?? []) {
if (diagnostic.type === "error") {
this.showError(diagnostic.message);
} else if (diagnostic.type === "warning") {
this.showWarning(diagnostic.message);
} else {
this.showStatus(diagnostic.message);
}
}
if (migratedProviders && migratedProviders.length > 0) {
this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
@ -2930,9 +3027,26 @@ export class InteractiveMode {
return;
}
if (text === "/model" || text.startsWith("/model ")) {
const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
const args = parseDefaultFlagArgs("model", text.startsWith("/model ") ? text.slice(7).trim() : undefined);
this.editor.setText("");
await this.handleModelCommand(searchTerm);
if (args.error) {
this.showError(args.error);
return;
}
await this.handleModelCommand(args.searchTerm, { persist: args.persist });
return;
}
if (text === "/thinking" || text.startsWith("/thinking ")) {
const args = parseDefaultFlagArgs(
"thinking",
text.startsWith("/thinking ") ? text.slice(10).trim() : undefined,
);
this.editor.setText("");
if (args.error) {
this.showError(args.error);
return;
}
this.handleThinkingCommand(args.searchTerm, { persist: args.persist });
return;
}
if (text === "/export" || text.startsWith("/export ")) {
@ -4473,9 +4587,14 @@ export class InteractiveMode {
private showSettingsSelector(): void {
this.showSelector((done) => {
let selector: SettingsSelectorComponent | undefined;
const defaultProvider = this.settingsManager.getDefaultProvider();
const defaultModelId = this.settingsManager.getDefaultModel();
const defaultModel = defaultProvider && defaultModelId ? `${defaultProvider}/${defaultModelId}` : "not set";
selector = new SettingsSelectorComponent(
{
autoCompact: this.session.autoCompactionEnabled,
defaultModel,
availableDefaultModels: this.session.modelRuntime.getAvailableSnapshot(),
showImages: this.settingsManager.getShowImages(),
imageWidthCells: this.settingsManager.getImageWidthCells(),
autoResizeImages: this.settingsManager.getImageAutoResize(),
@ -4485,8 +4604,9 @@ export class InteractiveMode {
followUpMode: this.session.followUpMode,
transport: this.settingsManager.getTransport(),
httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(),
thinkingLevel: this.session.thinkingLevel,
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
thinkingLevel: this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL,
availableThinkingLevels: [...THINKING_LEVEL_OPTIONS],
modelThinkingLevels: this.settingsManager.getAllModelThinkingLevels(),
currentTheme: this.themeController.getThemeSelection() || "dark",
terminalTheme: this.themeController.getTerminalTheme(),
availableThemes: getAvailableThemes(),
@ -4515,6 +4635,18 @@ export class InteractiveMode {
this.session.setAutoCompactionEnabled(enabled);
this.footer.setAutoCompactEnabled(enabled);
},
onDefaultModelChange: async (model) => {
try {
await this.session.setModel(model, { persist: true });
this.footer.invalidate();
this.updateEditorBorderColor();
this.showStatus(`Default model: ${model.provider}/${model.id}`);
void this.maybeWarnAboutAnthropicSubscriptionAuth(model);
this.checkDaxnutsEasterEgg(model);
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
}
},
onShowImagesChange: (enabled) => {
this.settingsManager.setShowImages(enabled);
for (const child of this.chatContainer.children) {
@ -4557,10 +4689,31 @@ export class InteractiveMode {
this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`);
},
onThinkingLevelChange: (level) => {
this.session.setThinkingLevel(level);
this.session.setThinkingLevel(level, { persist: true });
this.footer.invalidate();
this.updateEditorBorderColor();
},
onModelThinkingLevelChange: (provider, modelId, level) => {
this.settingsManager.setModelThinkingLevel(provider, modelId, level);
// If the override is for the current model, apply it to the session too
const current = this.session.model;
if (current && current.provider === provider && current.id === modelId) {
this.session.setThinkingLevel(level);
this.footer.invalidate();
this.updateEditorBorderColor();
}
},
onModelThinkingLevelRemove: (provider, modelId) => {
this.settingsManager.removeModelThinkingLevel(provider, modelId);
// If the override was for the current model, revert to global default
const current = this.session.model;
if (current && current.provider === provider && current.id === modelId) {
const globalDefault = this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;
this.session.setThinkingLevel(globalDefault);
this.footer.invalidate();
this.updateEditorBorderColor();
}
},
onThemeChange: (themeSetting) => {
this.settingsManager.setTheme(themeSetting);
void this.themeController.setThemeSetting(themeSetting);
@ -4683,19 +4836,67 @@ export class InteractiveMode {
});
}
private async handleModelCommand(searchTerm?: string): Promise<void> {
private handleThinkingCommand(searchTerm?: string, options: { persist?: boolean } = {}): void {
const availableLevels = this.session.getAvailableThinkingLevels();
if (!searchTerm) {
this.showModelSelector();
this.showThinkingSelector(options);
return;
}
const normalized = searchTerm.trim().toLowerCase();
const level = availableLevels.find((candidate) => candidate.toLowerCase() === normalized);
if (!level) {
this.showError(`Unknown thinking level "${searchTerm}". Available levels: ${availableLevels.join(", ")}.`);
return;
}
this.selectThinkingLevel(level, options.persist === true);
}
private selectThinkingLevel(level: ThinkingLevel, persist: boolean): void {
try {
this.session.setThinkingLevel(level, { persist });
this.footer.invalidate();
this.updateEditorBorderColor();
this.showStatus(persist ? `Default thinking level: ${level}` : `Thinking level: ${level}`);
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
}
}
private showThinkingSelector(options: { persist?: boolean } = {}): void {
this.showSelector((done) => {
const selectLevel = (level: ThinkingLevel, persist: boolean) => {
this.selectThinkingLevel(level, persist);
done();
};
const selector = new ThinkingSelectorComponent(
this.session.thinkingLevel ?? DEFAULT_THINKING_LEVEL,
this.session.getAvailableThinkingLevels(),
(level) => selectLevel(level, options.persist === true),
() => {
done();
this.ui.requestRender();
},
(level) => selectLevel(level, true),
);
return { component: selector, focus: selector };
});
}
private async handleModelCommand(searchTerm?: string, options: { persist?: boolean } = {}): Promise<void> {
if (!searchTerm) {
this.showModelSelector(undefined, options);
return;
}
const model = await this.findExactModelMatch(searchTerm);
if (model) {
try {
await this.session.setModel(model);
await this.session.setModel(model, { persist: options.persist === true });
this.footer.invalidate();
this.updateEditorBorderColor();
this.showStatus(`Model: ${model.id}`);
this.showStatus(options.persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`);
void this.maybeWarnAboutAnthropicSubscriptionAuth(model);
this.checkDaxnutsEasterEgg(model);
} catch (error) {
@ -4704,7 +4905,7 @@ export class InteractiveMode {
return;
}
this.showModelSelector(searchTerm);
this.showModelSelector(searchTerm, options);
}
private async findExactModelMatch(searchTerm: string): Promise<Model<any> | undefined> {
@ -4832,33 +5033,34 @@ export class InteractiveMode {
});
}
private showModelSelector(initialSearchInput?: string): void {
private showModelSelector(initialSearchInput?: string, options: { persist?: boolean } = {}): void {
this.showSelector((done) => {
const selectModel = async (model: Model<any>, persist: boolean) => {
try {
await this.session.setModel(model, { persist });
this.footer.invalidate();
this.updateEditorBorderColor();
done();
this.showStatus(persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`);
void this.maybeWarnAboutAnthropicSubscriptionAuth(model);
this.checkDaxnutsEasterEgg(model);
} catch (error) {
done();
this.showError(error instanceof Error ? error.message : String(error));
}
};
const selector = new ModelSelectorComponent(
this.ui,
this.session.model,
this.settingsManager,
this.session.modelRuntime,
this.session.scopedModels,
async (model) => {
try {
await this.session.setModel(model);
this.footer.invalidate();
this.updateEditorBorderColor();
done();
this.showStatus(`Model: ${model.id}`);
void this.maybeWarnAboutAnthropicSubscriptionAuth(model);
this.checkDaxnutsEasterEgg(model);
} catch (error) {
done();
this.showError(error instanceof Error ? error.message : String(error));
}
},
(model) => selectModel(model, options.persist === true),
() => {
done();
this.ui.requestRender();
},
initialSearchInput,
(model) => selectModel(model, true),
);
return { component: selector, focus: selector, dispose: () => selector.dispose() };
});
@ -5538,7 +5740,7 @@ export class InteractiveMode {
selectionError = `${actionLabel}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`;
} else {
try {
await this.session.setModel(selectedModel);
await this.session.setModel(selectedModel, { persist: true });
} catch (error: unknown) {
selectedModel = undefined;
const errorMessage = error instanceof Error ? error.message : String(error);

View file

@ -16,6 +16,7 @@ import { getCustomThemesDir, getThemesDir } from "../../../config.ts";
import type { SourceInfo } from "../../../core/source-info.ts";
import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.ts";
import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.ts";
import { stripBom } from "../../../utils/text.ts";
// ============================================================================
// Types & Schema
@ -474,8 +475,8 @@ function getBuiltinThemes(): Record<string, ThemeJson> {
const darkPath = path.join(themesDir, "dark.json");
const lightPath = path.join(themesDir, "light.json");
BUILTIN_THEMES = {
dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson,
light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson,
dark: JSON.parse(stripBom(fs.readFileSync(darkPath, "utf-8"))) as ThemeJson,
light: JSON.parse(stripBom(fs.readFileSync(lightPath, "utf-8"))) as ThemeJson,
};
}
return BUILTIN_THEMES;
@ -596,7 +597,7 @@ function parseThemeJson(label: string, json: unknown): ThemeJson {
function parseThemeJsonContent(label: string, content: string): ThemeJson {
let json: unknown;
try {
json = JSON.parse(content);
json = JSON.parse(stripBom(content));
} catch (error) {
throw new Error(`Failed to parse theme ${label}: ${error}`);
}

View file

@ -3,28 +3,45 @@ import type { AgentSessionEvent } from "../core/agent-session.ts";
type WithoutPartial<T> = T extends { partial: unknown } ? Omit<T, "partial"> : T;
type ToJsonEvent<T> = T extends {
type: "message_update";
assistantMessageEvent: infer TAssistantMessageEvent;
}
? {
type: "message_update";
usage: Usage;
assistantMessageEvent: WithoutPartial<TAssistantMessageEvent>;
}
: T;
/** Session event shape emitted by the JSON and RPC stdout protocols. */
export type JsonAgentSessionEvent = ToJsonEvent<AgentSessionEvent>;
type ToJsonAssistantMessageEvent<T> = T extends { type: "toolcall_start"; partial: unknown }
? WithoutPartial<T> & { id: string; toolName: string }
: WithoutPartial<T>;
type MessageUpdateEvent = Extract<AgentSessionEvent, { type: "message_update" }>;
type JsonMessageUpdateEvent = Extract<JsonAgentSessionEvent, { type: "message_update" }>;
type JsonMessageUpdateEvent = {
type: "message_update";
usage: Usage;
assistantMessageEvent: ToJsonAssistantMessageEvent<MessageUpdateEvent["assistantMessageEvent"]>;
};
/** Session event shape emitted by the JSON and RPC stdout protocols. */
export type JsonAgentSessionEvent = Exclude<AgentSessionEvent, { type: "message_update" }> | JsonMessageUpdateEvent;
function toJsonAssistantMessageEvent(
event: MessageUpdateEvent["assistantMessageEvent"],
): JsonMessageUpdateEvent["assistantMessageEvent"] {
if (event.type === "toolcall_start") {
const toolCall = event.partial.content[event.contentIndex];
if (toolCall?.type !== "toolCall") {
throw new Error(`toolcall_start content at index ${event.contentIndex} is not a tool call`);
}
const { partial: _partial, ...deltaEvent } = event;
return { ...deltaEvent, id: toolCall.id, toolName: toolCall.name };
}
if (!("partial" in event)) {
return event;
}
const { partial: _partial, ...deltaEvent } = event;
return deltaEvent;
}
/**
* Remove cumulative assistant snapshots from streaming wire events.
* `message_start` provides the initial message, deltas build it, and
* `message_end` provides the final authoritative message. Cumulative usage
* remains available because its size is constant.
* `message_end` provides the final authoritative message. Cumulative usage,
* tool-call ids, and tool names remain available because their size is constant.
*/
export function toJsonEvent(event: MessageUpdateEvent): JsonMessageUpdateEvent;
export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent;
@ -36,11 +53,9 @@ export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent {
throw new Error("message_update message is not an assistant message");
}
const assistantMessageEvent = event.assistantMessageEvent;
if (!("partial" in assistantMessageEvent)) {
return { type: "message_update", usage: event.message.usage, assistantMessageEvent };
}
const { partial: _partial, ...deltaEvent } = assistantMessageEvent;
return { type: "message_update", usage: event.message.usage, assistantMessageEvent: deltaEvent };
return {
type: "message_update",
usage: event.message.usage,
assistantMessageEvent: toJsonAssistantMessageEvent(event.assistantMessageEvent),
};
}

View file

@ -1,4 +1,5 @@
import { parse } from "yaml";
import { stripBom } from "./text.ts";
type ParsedFrontmatter<T extends Record<string, unknown>> = {
frontmatter: T;
@ -8,7 +9,7 @@ type ParsedFrontmatter<T extends Record<string, unknown>> = {
const normalizeNewlines = (value: string): string => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => {
const normalized = normalizeNewlines(content);
const normalized = normalizeNewlines(stripBom(content));
if (!normalized.startsWith("---")) {
return { yamlString: null, body: normalized };

View file

@ -0,0 +1,9 @@
/** Split a leading UTF-8 byte order mark from decoded text. */
export function splitBom(content: string): { bom: string; text: string } {
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
}
/** Remove a leading UTF-8 byte order mark from decoded text. */
export function stripBom(content: string): string {
return splitBom(content).text;
}

View file

@ -38,12 +38,14 @@ describe("Branch summary extensions", () => {
const targetId = harness.sessionManager.appendMessage(userMsg("first branch"));
harness.sessionManager.appendMessage(assistantMsg("first reply"));
harness.sessionManager.appendMessage(userMsg("abandoned branch work"));
harness.sessionManager.appendMessage(assistantMsg("abandoned reply"));
const sourceId = harness.sessionManager.appendMessage(assistantMsg("abandoned reply"));
const result = await harness.session.navigateTree(targetId, { summarize: true });
const summaryEntry = result.summaryEntry;
expect(summaryEntry?.type).toBe("branch_summary");
expect(summaryEntry?.parentId).toBeNull();
expect(summaryEntry?.fromId).toBe(sourceId);
expect(summaryEntry?.fromHook).toBe(true);
expect(summaryEntry?.summary).toBe("Summary provided by extension");
expect(summaryEntry?.usage).toEqual(usage);

View file

@ -115,7 +115,7 @@ describe("generateSummary reasoning options", () => {
expect(sessionIds[0]).not.toBe(sessionIds[1]);
});
it("honors caller-supplied cache retention and routing", async () => {
it("honors a caller-supplied routing session without prompt caching", async () => {
await completeSummarization(
createModel(false),
{ systemPrompt: "Summarize", messages: [] },
@ -124,7 +124,7 @@ describe("generateSummary reasoning options", () => {
expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({
sessionId: "current-routing-session",
cacheRetention: "long",
cacheRetention: "none",
toolChoice: "none",
});
});
@ -148,229 +148,6 @@ describe("generateSummary reasoning options", () => {
expect(prompt).toContain("<conversation>");
});
it("appends instructions to a cache-friendly source context", async () => {
const sourceContext: Context = {
systemPrompt: "You are a coding agent.",
messages: [{ role: "user", content: "Previous summary and original request", timestamp: 1 }],
tools: [],
};
const onPayload = async (payload: unknown) => payload;
const onResponse = async () => {};
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: messages,
turnPrefixMessages: [],
isSplitTurn: false,
tokensBefore: 100,
previousSummary: "Previous summary",
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
await compact(
preparation,
createModel(false),
"test-key",
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{
sourceContext,
requestOptions: {
sessionId: "routing-session",
onPayload,
onResponse,
transport: "websocket",
thinkingBudgets: { low: 1234 },
maxRetryDelayMs: 4321,
},
},
);
const requestContext = completeSimpleMock.mock.calls[0][1] as Context;
expect(requestContext.systemPrompt).toBe(sourceContext.systemPrompt);
expect(requestContext.tools).toBe(sourceContext.tools);
expect(requestContext.messages.slice(0, -1)).toEqual(sourceContext.messages);
const instruction = JSON.stringify(requestContext.messages.at(-1));
expect(instruction).toContain("existing structured summary of earlier conversation history");
expect(instruction).toContain("PRESERVE all existing information from the previous summary");
expect(instruction).not.toContain("<conversation>");
expect(instruction).not.toContain("<previous-summary>");
expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({
cacheRetention: "short",
sessionId: "routing-session",
toolChoice: "none",
transport: "websocket",
thinkingBudgets: { low: 1234 },
maxRetryDelayMs: 4321,
});
expect(completeSimpleMock.mock.calls[0][2]?.onPayload).toBe(onPayload);
expect(completeSimpleMock.mock.calls[0][2]?.onResponse).toBe(onResponse);
expect(sourceContext.messages).toHaveLength(1);
});
it("falls back to standalone summarization when a cache-friendly source cannot leave the summary budget", async () => {
const oversizedToolResult: AgentMessage = {
role: "toolResult",
toolCallId: "tool-call-1",
toolName: "read",
content: [{ type: "text", text: "x".repeat(800_000) }],
isError: false,
timestamp: 1,
};
const sourceContext: Context = {
systemPrompt: "You are a coding agent.",
messages: [oversizedToolResult],
tools: [],
};
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: [oversizedToolResult],
turnPrefixMessages: [],
isSplitTurn: false,
tokensBefore: 250_000,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
await compact(
preparation,
createModel(false),
"test-key",
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ sourceContext, requestOptions: { sessionId: "routing-session" } },
);
const requestContext = completeSimpleMock.mock.calls[0][1] as Context;
const prompt = JSON.stringify(requestContext.messages);
expect(requestContext.systemPrompt).not.toBe(sourceContext.systemPrompt);
expect(requestContext.tools).toBeUndefined();
expect(prompt).toContain("<conversation>");
expect(prompt).toContain("more characters truncated");
expect(prompt).not.toContain("x".repeat(3000));
expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ cacheRetention: "none", toolChoice: "none" });
expect(completeSimpleMock.mock.calls[0][2]?.sessionId).not.toBe("routing-session");
});
it("falls back to standalone summarization for an oversized split-turn source", async () => {
const splitUser = { role: "user" as const, content: "Large final request", timestamp: 1 };
const oversizedToolResult: AgentMessage = {
role: "toolResult",
toolCallId: "tool-call-1",
toolName: "read",
content: [{ type: "text", text: "x".repeat(800_000) }],
isError: false,
timestamp: 2,
};
const turnPrefixSourceContext: Context = {
systemPrompt: "You are a coding agent.",
messages: [splitUser, oversizedToolResult],
tools: [],
};
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: [],
turnPrefixMessages: [splitUser, oversizedToolResult],
isSplitTurn: true,
tokensBefore: 250_000,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
await compact(
preparation,
createModel(false),
"test-key",
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ turnPrefixSourceContext, requestOptions: { sessionId: "routing-session" } },
);
const requestContext = completeSimpleMock.mock.calls[0][1] as Context;
const prompt = JSON.stringify(requestContext.messages);
expect(requestContext.systemPrompt).not.toBe(turnPrefixSourceContext.systemPrompt);
expect(requestContext.tools).toBeUndefined();
expect(prompt).toContain("This is the PREFIX of a turn that was too large to keep");
expect(prompt).toContain("more characters truncated");
expect(prompt).not.toContain("source conversation may also contain complete earlier turns");
expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ cacheRetention: "none", toolChoice: "none" });
expect(completeSimpleMock.mock.calls[0][2]?.sessionId).not.toBe("routing-session");
});
it("limits a cache-friendly split-turn summary to the final incomplete turn", async () => {
const earlierUser = { role: "user" as const, content: "Earlier request", timestamp: 1 };
const earlierAssistant: AssistantMessage = {
...mockSummaryResponse,
content: [{ type: "text", text: "Earlier work completed" }],
timestamp: 2,
};
const splitUser = { role: "user" as const, content: "Large final request", timestamp: 3 };
const earlyAssistant: AssistantMessage = {
...mockSummaryResponse,
content: [{ type: "text", text: "Early work in final turn" }],
timestamp: 4,
};
const turnPrefixSourceContext: Context = {
systemPrompt: "You are a coding agent.",
messages: [earlierUser, earlierAssistant, splitUser, earlyAssistant],
tools: [],
};
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: [],
turnPrefixMessages: [splitUser, earlyAssistant],
isSplitTurn: true,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
await compact(
preparation,
createModel(false),
"test-key",
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ turnPrefixSourceContext },
);
const requestContext = completeSimpleMock.mock.calls[0][1] as Context;
expect(requestContext.messages.slice(0, -1)).toEqual(turnPrefixSourceContext.messages);
const instruction = JSON.stringify(requestContext.messages.at(-1));
expect(instruction).toContain("Summarize only the final, incomplete turn");
expect(instruction).toContain("last user-role request before this instruction");
expect(instruction).toContain("Do not summarize earlier turns");
expect(instruction).not.toContain("Earlier request");
expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({
cacheRetention: "short",
toolChoice: "none",
});
});
it("rejects tool calls from conversation summaries", async () => {
completeSimpleMock.mockResolvedValueOnce(mockToolCallResponse);
@ -436,14 +213,16 @@ describe("generateSummary reasoning options", () => {
expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("reasoning");
});
it("sets Anthropic refusal fallback from model metadata", async () => {
const fallbackCost = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 };
it("leaves Anthropic refusal fallback handling to pi-ai model metadata", async () => {
await generateSummary(
messages,
createModel(true, 8192, {
allowedFallbackModels: [
{ model: "claude-opus-4-8", cost: fallbackCost },
{ model: "claude-opus-5", cost: fallbackCost },
{
provider: "anthropic",
model: "claude-opus-4-8",
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
],
}),
2000,
@ -451,9 +230,7 @@ describe("generateSummary reasoning options", () => {
);
expect(completeSimpleMock).toHaveBeenCalledTimes(1);
expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({
refusalFallbacks: [{ model: "claude-opus-4-8", cost: fallbackCost }],
});
expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("refusalFallbacks");
});
it("does not set Anthropic refusal fallback for models without allowed fallback targets", async () => {

View file

@ -5,7 +5,6 @@ import { readFileSync } from "fs";
import { join } from "path";
import { beforeEach, describe, expect, it } from "vitest";
import {
type CompactionPreparation,
type CompactionSettings,
calculateContextTokens,
compact,
@ -151,17 +150,6 @@ function createCustomMessageEntry(content: string): CustomMessageEntry {
return entry;
}
function requireSourceMessages(
preparation: CompactionPreparation | undefined,
): asserts preparation is CompactionPreparation & {
sourceMessages: AgentMessage[];
turnPrefixSourceMessages: AgentMessage[];
} {
if (!preparation?.sourceMessages || !preparation.turnPrefixSourceMessages) {
throw new Error("Expected compaction source messages");
}
}
function extractText(messages: AgentMessage[]): string {
return messages
.map((message) => {
@ -473,44 +461,6 @@ describe("buildSessionContext", () => {
});
});
describe("prepareCompaction source messages", () => {
it("provides the active history prefix for a normal compaction", () => {
const u1 = createMessageEntry(createUserMessage("old user ".repeat(20)));
const a1 = createMessageEntry(createAssistantMessage("old assistant ".repeat(20)));
const u2 = createMessageEntry(createUserMessage("recent user"));
const a2 = createMessageEntry(createAssistantMessage("ok"));
const settings: CompactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 3 };
const preparation = prepareCompaction([u1, a1, u2, a2], settings);
expect(preparation).toBeDefined();
requireSourceMessages(preparation);
expect(preparation.isSplitTurn).toBe(false);
expect(preparation.sourceMessages).toEqual(preparation.messagesToSummarize);
expect(extractText(preparation.sourceMessages)).toContain("old user");
expect(extractText(preparation.sourceMessages)).not.toContain("recent user");
expect(preparation.turnPrefixSourceMessages).toEqual([]);
});
it("provides the active prefix through a split turn", () => {
const user = createMessageEntry(createUserMessage("large request ".repeat(20)));
const earlyAssistant = createMessageEntry(createAssistantMessage("early work ".repeat(20)));
const keptAssistant = createMessageEntry(createAssistantMessage("kept"));
const settings: CompactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 1 };
const preparation = prepareCompaction([user, earlyAssistant, keptAssistant], settings);
expect(preparation).toBeDefined();
requireSourceMessages(preparation);
expect(preparation.isSplitTurn).toBe(true);
expect(preparation.messagesToSummarize).toEqual([]);
expect(preparation.sourceMessages).toEqual([]);
expect(preparation.turnPrefixSourceMessages).toEqual(preparation.turnPrefixMessages);
expect(extractText(preparation.turnPrefixSourceMessages)).toContain("large request");
expect(extractText(preparation.turnPrefixSourceMessages)).not.toContain("kept");
});
});
describe("prepareCompaction with previous compaction", () => {
it("should skip repeated compactions when kept messages still fit", () => {
const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)"));
@ -529,33 +479,6 @@ describe("prepareCompaction with previous compaction", () => {
expect(preparation).toBeUndefined();
});
it("preserves retained older summaries in the active source prefix", () => {
const u1 = createMessageEntry(createUserMessage("user 1"));
const a1 = createMessageEntry(createAssistantMessage("assistant 1"));
const u2 = createMessageEntry(createUserMessage("user 2 ".repeat(20)));
const a2 = createMessageEntry(createAssistantMessage("assistant 2 ".repeat(20)));
const compaction1 = createCompactionEntry("First summary", u1.id);
const u3 = createMessageEntry(createUserMessage("user 3 ".repeat(20)));
const a3 = createMessageEntry(createAssistantMessage("assistant 3 ".repeat(20)));
const compaction2 = createCompactionEntry("Second summary", u2.id);
const u4 = createMessageEntry(createUserMessage("user 4 ".repeat(20)));
const a4 = createMessageEntry(createAssistantMessage("kept"));
const settings: CompactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 1 };
const pathEntries = [u1, a1, u2, a2, compaction1, u3, a3, compaction2, u4, a4];
const preparation = prepareCompaction(pathEntries, settings);
expect(preparation).toBeDefined();
requireSourceMessages(preparation);
const activeMessages = buildSessionContext(pathEntries).messages;
expect(preparation.sourceMessages).toEqual(activeMessages.slice(0, preparation.sourceMessages.length));
expect(preparation.turnPrefixSourceMessages).toEqual(
activeMessages.slice(0, preparation.turnPrefixSourceMessages.length),
);
const sourceSummaries = preparation.sourceMessages.filter((message) => message.role === "compactionSummary");
expect(sourceSummaries.map((message) => message.summary)).toEqual(["Second summary", "First summary"]);
});
it("should re-summarize previously kept messages when the recent window moves past them", () => {
const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)".repeat(4)));
const a1 = createMessageEntry(createAssistantMessage("assistant msg 1".repeat(4)));
@ -574,15 +497,11 @@ describe("prepareCompaction with previous compaction", () => {
const preparation = prepareCompaction([u1, a1, u2, a2, u3, a3, compaction1, u4, a4], settings);
expect(preparation).toBeDefined();
requireSourceMessages(preparation);
const summarizedText = extractText(preparation.messagesToSummarize);
const summarizedText = extractText(preparation!.messagesToSummarize);
expect(summarizedText).toContain("user msg 2 - kept by compaction1");
expect(summarizedText).toContain("user msg 3 - kept by compaction1");
expect(summarizedText).not.toContain("First summary");
expect(preparation.previousSummary).toBe("First summary");
expect(preparation.sourceMessages[0]?.role).toBe("compactionSummary");
expect(extractText(preparation.sourceMessages)).toContain("First summary");
expect(preparation.sourceMessages.slice(1)).toEqual(preparation.messagesToSummarize);
expect(preparation!.previousSummary).toBe("First summary");
});
});

View file

@ -8,7 +8,7 @@ import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts";
import type { SourceInfo } from "../src/core/source-info.ts";
import type { AuthSelectorProvider } from "../src/modes/interactive/components/oauth-selector.ts";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
import { InteractiveMode, parseDefaultFlagArgs } from "../src/modes/interactive/interactive-mode.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
function renderLastLine(container: Container, width = 120): string {
@ -401,6 +401,22 @@ describe("InteractiveMode.setupAutocompleteProvider", () => {
});
describe("InteractiveMode.createBaseAutocompleteProvider", () => {
describe("parseDefaultFlagArgs", () => {
test("parses --default as a persistent model selection", () => {
expect(parseDefaultFlagArgs("model", "--default openai/gpt-5")).toEqual({
persist: true,
searchTerm: "openai/gpt-5",
});
});
test("rejects unknown /model flags", () => {
expect(parseDefaultFlagArgs("model", "--global openai/gpt-5")).toMatchObject({
persist: false,
error: 'Unknown /model option "--global". Supported option: --default.',
});
});
});
test("matches model command arguments across provider/model order", async () => {
type TestModel = { id: string; provider: string; name: string };
type FakeInteractiveMode = {
@ -452,6 +468,52 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => {
]);
});
test("preserves --default when completing model command arguments", async () => {
type TestModel = { id: string; provider: string; name: string };
type FakeInteractiveMode = {
session: {
scopedModels: Array<{ model: TestModel }>;
modelRuntime: { getAvailableSnapshot: () => TestModel[] };
promptTemplates: [];
extensionRunner: { getRegisteredCommands: () => [] };
resourceLoader: { getSkills: () => { skills: [] } };
};
settingsManager: { getEnableSkillCommands: () => boolean };
skillCommands: Map<string, string>;
sessionManager: { getCwd: () => string };
fdPath: null;
};
const createBaseAutocompleteProvider = (
InteractiveMode as unknown as {
prototype: { createBaseAutocompleteProvider(this: FakeInteractiveMode): AutocompleteProvider };
}
).prototype.createBaseAutocompleteProvider;
const models = [{ id: "gpt-5.5", provider: "openai-codex", name: "GPT-5.5" }];
const fakeThis: FakeInteractiveMode = {
session: {
scopedModels: [],
modelRuntime: { getAvailableSnapshot: () => models },
promptTemplates: [],
extensionRunner: { getRegisteredCommands: () => [] },
resourceLoader: { getSkills: () => ({ skills: [] }) },
},
settingsManager: { getEnableSkillCommands: () => false },
skillCommands: new Map(),
sessionManager: { getCwd: () => "/tmp" },
fdPath: null,
};
const provider = createBaseAutocompleteProvider.call(fakeThis);
const line = "/model --default codex";
const suggestions = await provider.getSuggestions([line], 0, line.length, {
signal: new AbortController().signal,
});
expect(suggestions?.prefix).toBe("--default codex");
expect(suggestions?.items[0]?.value).toBe("--default openai-codex/gpt-5.5");
});
test("matches login command arguments by provider id and name", async () => {
type FakeInteractiveMode = {
session: {

View file

@ -34,7 +34,6 @@ describe("model selector", () => {
const selector = new ModelSelectorComponent(
createFakeTui(),
harness.getModel(),
harness.settingsManager,
harness.session.modelRuntime,
[],
() => {},

View file

@ -321,12 +321,12 @@ describe("SessionManager append and tree traversal", () => {
});
describe("branchWithSummary", () => {
it("inserts branch summary and advances leaf", () => {
it("inserts branch summary with the source and destination and advances leaf", () => {
const session = SessionManager.inMemory();
const id1 = session.appendMessage(userMsg("1"));
const _id2 = session.appendMessage(assistantMsg("2"));
const _id3 = session.appendMessage(userMsg("3"));
const id3 = session.appendMessage(userMsg("3"));
const usage = {
input: 10,
@ -345,6 +345,7 @@ describe("SessionManager append and tree traversal", () => {
expect(summaryEntry).toBeDefined();
expect(summaryEntry?.parentId).toBe(id1);
if (summaryEntry?.type === "branch_summary") {
expect(summaryEntry.fromId).toBe(id3);
expect(summaryEntry.summary).toBe("Summary of abandoned work");
expect(summaryEntry.usage).toEqual(usage);
}

View file

@ -0,0 +1,47 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { collectSettingsDiagnostics, deduplicateDiagnostics } from "../src/core/settings-diagnostics.ts";
import { SettingsManager, type SettingsStorage } from "../src/core/settings-manager.ts";
describe("settings diagnostics", () => {
it("includes the settings file path for file-backed storage", () => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-settings-diagnostics-"));
const agentDir = join(tempDir, "agent");
const settingsPath = join(agentDir, "settings.json");
mkdirSync(agentDir);
writeFileSync(settingsPath, "{");
try {
const diagnostics = collectSettingsDiagnostics(SettingsManager.create(tempDir, agentDir));
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0]?.type).toBe("warning");
expect(diagnostics[0]?.message).toContain(`Invalid settings file ${settingsPath}:`);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it("falls back to the settings scope for storage without file paths", () => {
const storage: SettingsStorage = {
withLock(scope, fn) {
if (scope === "global") throw new Error("backend failed");
fn(undefined);
},
};
const diagnostics = collectSettingsDiagnostics(SettingsManager.fromStorage(storage));
expect(diagnostics).toEqual([{ type: "warning", message: "Invalid global settings: backend failed" }]);
});
it("deduplicates diagnostics by type and message", () => {
const warning = { type: "warning" as const, message: "Invalid settings file /tmp/settings.json" };
expect(deduplicateDiagnostics([warning, warning, { ...warning, type: "error" }])).toEqual([
warning,
{ ...warning, type: "error" },
]);
});
});

View file

@ -185,7 +185,7 @@ describe("SettingsManager", () => {
expect(manager.getDefaultModel()).toBe("claude-sonnet");
});
it("should keep previous settings when file is invalid", async () => {
it("should keep previous settings and report the file path when the file is invalid", async () => {
const settingsPath = join(agentDir, "settings.json");
writeFileSync(settingsPath, JSON.stringify({ theme: "dark" }));
@ -195,6 +195,7 @@ describe("SettingsManager", () => {
await manager.reload();
expect(manager.getTheme()).toBe("dark");
expect(manager.drainErrors()).toMatchObject([{ scope: "global", path: settingsPath }]);
});
});
@ -227,7 +228,10 @@ describe("SettingsManager", () => {
const errors = manager.drainErrors();
expect(errors).toHaveLength(2);
expect(errors.map((e) => e.scope).sort()).toEqual(["global", "project"]);
expect(errors).toMatchObject([
{ scope: "global", path: globalSettingsPath },
{ scope: "project", path: projectSettingsPath },
]);
expect(manager.drainErrors()).toEqual([]);
});
});

View file

@ -21,7 +21,10 @@ describe("SettingsSelectorComponent", () => {
fullscreenExitOutput: "transcript",
fullscreenScrollbar: "auto",
warnings: {},
defaultModel: "not set",
availableDefaultModels: [],
availableThinkingLevels: [],
modelThinkingLevels: {},
availableThemes: [],
} as unknown as SettingsConfig;
const callbacks = {

View file

@ -14,7 +14,7 @@ describe("AgentSession model and extension characterization", () => {
}
});
it("setModel saves the model and emits model_select", async () => {
it("setModel saves the model to the session and emits model_select", async () => {
const modelEvents: string[] = [];
const harness = await createHarness({
models: [
@ -42,6 +42,112 @@ describe("AgentSession model and extension characterization", () => {
.filter((entry) => entry.type === "model_change")
.map((entry) => `${entry.provider}/${entry.modelId}`),
).toEqual([`${nextModel.provider}/${nextModel.id}`]);
expect(harness.settingsManager.getDefaultProvider()).toBeUndefined();
expect(harness.settingsManager.getDefaultModel()).toBeUndefined();
});
it("only persists model and thinking defaults when requested", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
],
});
harnesses.push(harness);
const nextModel = harness.getModel("faux-2")!;
await harness.session.setModel(nextModel);
expect(harness.settingsManager.getDefaultProvider()).toBeUndefined();
expect(harness.settingsManager.getDefaultModel()).toBeUndefined();
harness.session.setThinkingLevel("low");
expect(harness.settingsManager.getDefaultThinkingLevel()).toBeUndefined();
await harness.session.setModel(nextModel, { persist: true });
expect(harness.settingsManager.getDefaultProvider()).toBe(nextModel.provider);
expect(harness.settingsManager.getDefaultModel()).toBe(nextModel.id);
harness.session.setThinkingLevel("high", { persist: true });
expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("high");
});
it("persists the requested default thinking level even when the current model clamps it", async () => {
const harness = await createHarness({ models: [{ id: "faux-1", reasoning: true }] });
harnesses.push(harness);
harness.session.setThinkingLevel("max", { persist: true });
expect(harness.session.thinkingLevel).toBe("high");
expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("max");
});
it("cycleModel and cycleThinkingLevel are session-only by default", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
],
settings: {
defaultProvider: "faux",
defaultModel: "faux-1",
defaultThinkingLevel: "low",
},
});
harnesses.push(harness);
await harness.session.cycleModel();
expect(harness.session.model?.id).toBe("faux-2");
expect(harness.settingsManager.getDefaultModel()).toBe("faux-1");
harness.session.setThinkingLevel("off");
expect(harness.session.cycleThinkingLevel()).toBe("minimal");
expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("low");
});
it("applies per-model thinking level override on model switch", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
],
});
harnesses.push(harness);
// Set a per-model override for faux-2
harness.settingsManager.setModelThinkingLevel("faux", "faux-2", "low");
// Session starts on faux-1 with default thinking
harness.session.setThinkingLevel("high");
expect(harness.session.thinkingLevel).toBe("high");
// Switch to faux-2 → per-model override should apply
const model2 = harness.getModel("faux-2")!;
await harness.session.setModel(model2);
expect(harness.session.thinkingLevel).toBe("low");
// Switch back to faux-1 → no per-model override, carries session level
const model1 = harness.getModel("faux-1")!;
await harness.session.setModel(model1);
expect(harness.session.thinkingLevel).toBe("low");
});
it("per-model override takes priority over global default during model switch", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
],
settings: {
defaultThinkingLevel: "high",
modelThinkingLevels: { "faux/faux-2": "minimal" },
},
});
harnesses.push(harness);
// Start on a non-thinking model, then switch to faux-2
const model2 = harness.getModel("faux-2")!;
await harness.session.setModel(model2);
expect(harness.session.thinkingLevel).toBe("minimal");
});
it("cycles through scoped models and preserves the scoped thinking preference", async () => {

View file

@ -78,7 +78,6 @@ describe("issue #3217 scoped model ordering", () => {
const selector = new ModelSelectorComponent(
createFakeTui(),
modelOne,
harness.settingsManager,
harness.session.modelRuntime,
[{ model: modelTwo }, { model: modelOne }, { model: modelThree }],
() => {},

View file

@ -5,7 +5,6 @@ import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../../../src/core/auth-storage.ts";
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
import { SettingsManager } from "../../../src/core/settings-manager.ts";
import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
@ -69,7 +68,6 @@ describe("issue #6999 models.json hot reload", () => {
const selector = new ModelSelectorComponent(
tui,
undefined,
SettingsManager.inMemory(),
modelRuntime,
[],
() => {},

View file

@ -51,7 +51,6 @@ describe("model selector filter resets selection to top", () => {
const selector = new ModelSelectorComponent(
createFakeTui(),
current,
harness.settingsManager,
harness.session.modelRuntime,
[],
() => {},
@ -102,7 +101,6 @@ describe("model selector filter resets selection to top", () => {
const selector = new ModelSelectorComponent(
createFakeTui(),
alpha1,
harness.settingsManager,
harness.session.modelRuntime,
[{ model: alpha2 }, { model: alpha3 }, { model: alpha1 }],
() => {},

View file

@ -0,0 +1,56 @@
import { Container } from "@earendil-works/pi-tui";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { AgentSessionRuntimeDiagnostic } from "../../../src/core/agent-session-services.ts";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { createHarness } from "../harness.ts";
function render(container: Container): string {
return container.children.flatMap((child) => child.render(120)).join("\n");
}
describe("issue #7829 invalid settings warning", () => {
beforeAll(() => initTheme("dark"));
it("renders startup diagnostics inside the transcript", async () => {
const harness = await createHarness();
const previousOffline = process.env.PI_OFFLINE;
process.env.PI_OFFLINE = "1";
try {
const chatContainer = new Container();
const startupDiagnostics: AgentSessionRuntimeDiagnostic[] = [
{
type: "warning",
message: "Invalid settings file /tmp/settings.json: malformed JSON",
},
];
const context = {
init: vi.fn(async () => {}),
options: { startupDiagnostics },
chatContainer,
outputPad: 1,
ui: { requestRender: vi.fn() },
version: "test",
showWarning: (InteractiveMode.prototype as unknown as { showWarning(message: string): void }).showWarning,
session: harness.session,
checkForPackageUpdates: vi.fn().mockResolvedValue([]),
checkTmuxKeyboardSetup: vi.fn().mockResolvedValue(undefined),
maybeWarnAboutAnthropicSubscriptionAuth: vi.fn(),
getUserInput: vi.fn(() => new Promise<string>(() => {})),
};
const run = (InteractiveMode.prototype as unknown as { run(this: typeof context): Promise<void> }).run;
void run.call(context);
await vi.waitFor(() => {
expect(render(chatContainer)).toContain(
"Warning: Invalid settings file /tmp/settings.json: malformed JSON",
);
});
} finally {
if (previousOffline === undefined) delete process.env.PI_OFFLINE;
else process.env.PI_OFFLINE = previousOffline;
harness.cleanup();
}
});
});

View file

@ -0,0 +1,43 @@
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { toJsonEvent } from "../../../src/modes/json-event.ts";
import { createHarness, type Harness } from "../harness.ts";
describe("regression #7925: tool-call metadata is available when streaming starts", () => {
let harness: Harness | undefined;
afterEach(() => {
harness?.cleanup();
});
it("includes the tool call id and name without cumulative snapshots", async () => {
harness = await createHarness();
harness.setResponses([
fauxAssistantMessage(
fauxToolCall("write", { path: "output.txt", content: "x".repeat(100) }, { id: "call_7925" }),
{ stopReason: "toolUse" },
),
fauxAssistantMessage("done"),
]);
await harness.session.prompt("write a file");
const update = harness
.eventsOfType("message_update")
.find((event) => event.assistantMessageEvent.type === "toolcall_start");
if (!update || update.message.role !== "assistant") {
throw new Error("Expected toolcall_start assistant update");
}
expect(toJsonEvent(update)).toEqual({
type: "message_update",
usage: update.message.usage,
assistantMessageEvent: {
type: "toolcall_start",
contentIndex: 0,
id: "call_7925",
toolName: "write",
},
});
});
});

View file

@ -0,0 +1,80 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createHarness, type Harness } from "../harness.ts";
type SessionWithCompactionInternals = {
_checkCompaction: (assistantMessage: AssistantMessage) => Promise<boolean>;
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<boolean>;
};
function createZeroUsageAssistant(harness: Harness): AssistantMessage {
const model = harness.getModel();
return {
role: "assistant",
content: [{ type: "text", text: "response" }],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
describe("issue #8328 zero-usage auto-compaction", () => {
const harnesses: Harness[] = [];
afterEach(() => {
vi.restoreAllMocks();
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
async function createCompactionHarness(): Promise<Harness> {
const harness = await createHarness({
models: [{ id: "faux-1", contextWindow: 100, maxTokens: 20 }],
settings: { compaction: { enabled: true, reserveTokens: 10 } },
});
harnesses.push(harness);
return harness;
}
it("uses the message estimate when no assistant has reported usage", async () => {
const harness = await createCompactionHarness();
const assistant = createZeroUsageAssistant(harness);
harness.session.agent.state.messages = [
{ role: "user", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() - 1 },
assistant,
];
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false);
await sessionInternals._checkCompaction(assistant);
expect(runAutoCompactionSpy).toHaveBeenCalledOnce();
expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false);
});
it("does not compact when the zero-usage message estimate is below the threshold", async () => {
const harness = await createCompactionHarness();
const assistant = createZeroUsageAssistant(harness);
harness.session.agent.state.messages = [
{ role: "user", content: [{ type: "text", text: "short" }], timestamp: Date.now() - 1 },
assistant,
];
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false);
await sessionInternals._checkCompaction(assistant);
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,49 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SettingsManager } from "../../../src/core/settings-manager.ts";
import { parseFrontmatter } from "../../../src/utils/frontmatter.ts";
import { splitBom } from "../../../src/utils/text.ts";
describe("issue #8337 UTF-8 BOM parsing", () => {
let testDir: string;
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), "pi-8337-"));
});
afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
it("loads frontmatter and settings with a leading BOM", async () => {
expect(splitBom("\uFEFFcontent")).toEqual({ bom: "\uFEFF", text: "content" });
const document = "---\nname: demo\ndescription: Test\n---\nBody";
expect(parseFrontmatter(`\uFEFF${document}`)).toEqual({
frontmatter: { name: "demo", description: "Test" },
body: "Body",
});
const agentDir = join(testDir, "agent");
const projectDir = join(testDir, "project");
mkdirSync(join(projectDir, ".pi"), { recursive: true });
mkdirSync(agentDir, { recursive: true });
const globalSettingsPath = join(agentDir, "settings.json");
writeFileSync(globalSettingsPath, `\uFEFF${JSON.stringify({ defaultModel: "global-model" })}`);
writeFileSync(
join(projectDir, ".pi", "settings.json"),
`\uFEFF${JSON.stringify({ defaultProvider: "project-provider" })}`,
);
const settings = SettingsManager.create(projectDir, agentDir);
expect(settings.getDefaultModel()).toBe("global-model");
expect(settings.getDefaultProvider()).toBe("project-provider");
settings.setTheme("dark");
await settings.flush();
expect(readFileSync(globalSettingsPath, "utf-8")).not.toMatch(/^\uFEFF/);
});
});

View file

@ -15,8 +15,12 @@ export interface SettingItem {
currentValue: string;
/** If provided, Enter/Space cycles through these values */
values?: string[];
/** If provided, Enter opens this submenu. Receives current value and done callback. */
submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
/** If provided, Enter opens this submenu. Receives current value and done callback.
* done() accepts an optional selectedValue and an optional navigateTo id to move the cursor after close. */
submenu?: (
currentValue: string,
done: (selectedValue?: string, options?: { navigateTo?: string }) => void,
) => Component;
}
export interface SettingsListTheme {
@ -46,6 +50,7 @@ export class SettingsList implements Component {
// Submenu state
private submenuComponent: Component | null = null;
private submenuItemIndex: number | null = null;
private navigateAfterClose: string | null = null;
constructor(
items: SettingItem[],
@ -75,6 +80,15 @@ export class SettingsList implements Component {
}
}
/** Move selection to the item with the given id (no-op if not found). */
selectItem(id: string): void {
const items = this.searchEnabled ? this.filteredItems : this.items;
const index = items.findIndex((i) => i.id === id);
if (index !== -1) {
this.selectedIndex = index;
}
}
invalidate(): void {
this.submenuComponent?.invalidate?.();
}
@ -258,13 +272,19 @@ export class SettingsList implements Component {
if (item.submenu) {
// Open submenu, passing current value so it can pre-select correctly
this.submenuItemIndex = this.selectedIndex;
this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => {
if (selectedValue !== undefined) {
item.currentValue = selectedValue;
this.onChange(item.id, selectedValue);
}
this.closeSubmenu();
});
this.submenuComponent = item.submenu(
item.currentValue,
(selectedValue?: string, options?: { navigateTo?: string }) => {
if (selectedValue !== undefined) {
item.currentValue = selectedValue;
this.onChange(item.id, selectedValue);
}
if (options?.navigateTo) {
this.navigateAfterClose = options.navigateTo;
}
this.closeSubmenu();
},
);
} else if (item.values && item.values.length > 0) {
// Cycle through values
const currentIndex = item.values.indexOf(item.currentValue);
@ -277,8 +297,15 @@ export class SettingsList implements Component {
private closeSubmenu(): void {
this.submenuComponent = null;
// Restore selection to the item that opened the submenu
if (this.submenuItemIndex !== null) {
if (this.navigateAfterClose !== null) {
const id = this.navigateAfterClose;
this.navigateAfterClose = null;
this.submenuItemIndex = null;
this.selectItem(id);
// Open the target item's submenu automatically
this.activateItem();
} else if (this.submenuItemIndex !== null) {
// Restore selection to the item that opened the submenu
this.selectedIndex = this.submenuItemIndex;
this.submenuItemIndex = null;
}