mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Merge remote-tracking branch 'origin/main' into dev
This commit is contained in:
commit
d94e521baa
81 changed files with 2521 additions and 1193 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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>>> = {};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
274
packages/ai/test/bedrock-redacted-reasoning.test.ts
Normal file
274
packages/ai/test/bedrock-redacted-reasoning.test.ts
Normal 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" } } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
25
packages/coding-agent/src/core/settings-diagnostics.ts
Normal file
25
packages/coding-agent/src/core/settings-diagnostics.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ export { readStoredCredential } from "./core/auth-storage.ts";
|
|||
export {
|
||||
type BranchPreparation,
|
||||
type BranchSummaryResult,
|
||||
type CacheFriendlySummaryOptions,
|
||||
type CollectEntriesResult,
|
||||
type CompactionResult,
|
||||
type CutPointResult,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
9
packages/coding-agent/src/utils/text.ts
Normal file
9
packages/coding-agent/src/utils/text.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ describe("model selector", () => {
|
|||
const selector = new ModelSelectorComponent(
|
||||
createFakeTui(),
|
||||
harness.getModel(),
|
||||
harness.settingsManager,
|
||||
harness.session.modelRuntime,
|
||||
[],
|
||||
() => {},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
47
packages/coding-agent/test/settings-diagnostics.test.ts
Normal file
47
packages/coding-agent/test/settings-diagnostics.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ describe("SettingsSelectorComponent", () => {
|
|||
fullscreenExitOutput: "transcript",
|
||||
fullscreenScrollbar: "auto",
|
||||
warnings: {},
|
||||
defaultModel: "not set",
|
||||
availableDefaultModels: [],
|
||||
availableThinkingLevels: [],
|
||||
modelThinkingLevels: {},
|
||||
availableThemes: [],
|
||||
} as unknown as SettingsConfig;
|
||||
const callbacks = {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
() => {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
[],
|
||||
() => {},
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
() => {},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue