fix: align v2 model thinking and mcp startup

This commit is contained in:
7Sageer 2026-07-06 20:34:10 +08:00
parent d9d4a413f7
commit 669bb8e04d
31 changed files with 1648 additions and 244 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix MCP tools being unavailable on the first turn after session startup.

View file

@ -285,14 +285,30 @@ export class AgentProfileService implements IAgentProfileService {
resolveModel(): Model | undefined {
if (this.modelAlias === undefined) return undefined;
let model: Model = this.modelFactory.resolve(this.modelAlias);
model = model.withThinking(this.thinkingLevel);
const thinkingLevel = this.thinkingLevel;
const kwargs: GenerationKwargs = {};
if (model.protocol === 'kimi') {
kwargs.prompt_cache_key = this.sessionContext.sessionId;
} else if (model.protocol === 'anthropic') {
model = model.withProviderOptions({
metadata: { user_id: this.sessionContext.sessionId },
});
}
const overrides = this.config.get<KimiModelOverrides>('modelOverrides');
if (overrides !== undefined) {
const kwargs: GenerationKwargs = {};
if (overrides.temperature !== undefined) kwargs.temperature = overrides.temperature;
if (overrides.topP !== undefined) kwargs.top_p = overrides.topP;
if (Object.keys(kwargs).length > 0) model = model.withGenerationKwargs(kwargs);
if (
model.protocol === 'kimi' &&
thinkingLevel !== 'off' &&
overrides.thinkingKeep !== undefined &&
overrides.thinkingKeep.length > 0
) {
kwargs.extra_body = { thinking: { keep: overrides.thinkingKeep } };
}
}
if (Object.keys(kwargs).length > 0) model = model.withGenerationKwargs(kwargs);
model = model.withThinking(thinkingLevel);
return model;
}
@ -355,9 +371,11 @@ export class AgentProfileService implements IAgentProfileService {
if (changed.modelAlias !== undefined) payload.modelAlias = changed.modelAlias;
if (changed.profileName !== undefined) payload.profileName = changed.profileName;
if (changed.thinkingLevel !== undefined) {
const model = this.resolveModelForThinking(changed.modelAlias);
payload.thinkingLevel = resolveThinkingEffort(
changed.thinkingLevel,
this.config.get<ThinkingConfig>(THINKING_SECTION),
model,
);
}
if (changed.systemPrompt !== undefined) payload.systemPrompt = changed.systemPrompt;
@ -423,7 +441,11 @@ export class AgentProfileService implements IAgentProfileService {
private get thinkingLevel(): ThinkingEffort {
const stored = this.profileState.thinkingLevel;
if (stored === 'off' && this.alwaysThinkingModel) {
return resolveThinkingEffort('on', this.config.get<ThinkingConfig>(THINKING_SECTION));
return resolveThinkingEffort(
'on',
this.config.get<ThinkingConfig>(THINKING_SECTION),
this.tryResolveRawModel(),
);
}
return stored;
}
@ -434,6 +456,10 @@ export class AgentProfileService implements IAgentProfileService {
private tryResolveRawModel(): Model | undefined {
const alias = this.modelAlias;
return this.resolveModelForThinking(alias);
}
private resolveModelForThinking(alias: string | undefined): Model | undefined {
if (alias === undefined) return undefined;
try {
return this.modelFactory.resolve(alias);

View file

@ -7,15 +7,17 @@
*/
import type { ThinkingEffort } from '#/app/llmProtocol';
import {
type ModelThinkingMetadata,
resolveThinkingEffortForModel,
} from '#/app/model/thinking';
import type { ThinkingConfig } from './configSection';
const DEFAULT_THINKING_EFFORT: ThinkingEffort = 'high';
const THINKING_EFFORTS = new Set<ThinkingEffort>(['low', 'medium', 'high', 'xhigh', 'max']);
export interface ResolveThinkingLevelOptions {
readonly defaultThinking?: boolean;
readonly thinking?: ThinkingConfig;
readonly model?: ModelThinkingMetadata;
}
export function resolveThinkingLevel(
@ -29,27 +31,13 @@ export function resolveThinkingLevel(
? 'off'
: undefined;
return resolveThinkingEffort(resolvedRequest, options.thinking);
return resolveThinkingEffort(resolvedRequest, options.thinking, options.model);
}
export function resolveThinkingEffort(
requested: string | undefined,
defaults: ThinkingConfig | undefined,
model?: ModelThinkingMetadata,
): ThinkingEffort {
const configEffort = parseEffort(defaults?.effort) ?? DEFAULT_THINKING_EFFORT;
const normalized = requested?.trim().toLowerCase();
if (!normalized) {
if (defaults?.mode === 'off') return 'off';
return configEffort;
}
if (normalized === 'off') return 'off';
if (normalized === 'on') return configEffort;
return parseEffort(normalized) ?? configEffort;
}
function parseEffort(value: string | undefined): ThinkingEffort | undefined {
const normalized = value?.trim().toLowerCase();
return normalized !== undefined && THINKING_EFFORTS.has(normalized as ThinkingEffort)
? (normalized as ThinkingEffort)
: undefined;
return resolveThinkingEffortForModel(requested, defaults, model);
}

View file

@ -5,13 +5,14 @@ import type { TokenUsage } from './usage';
/**
* Normalized thinking effort level used across providers.
*
* Values above `high` are provider/model-specific and may be clamped by the
* adapter when the native API has no matching level. OpenAI maps `max` to its
* `xhigh` ceiling; Kimi and Gemini cap `xhigh`/`max` at `high`; Anthropic
* supports `xhigh`/`max` only on selected models and otherwise clamps to
* `high`.
* `'on'` is the boolean-thinking signal for models that do not expose named
* efforts. Values above `high` are provider/model-specific and may be clamped
* by the adapter when the native API has no matching level. OpenAI maps `max`
* to its `xhigh` ceiling; Kimi and Gemini cap `xhigh`/`max` at `high`;
* Anthropic supports `xhigh`/`max` only on selected models and otherwise clamps
* to `high`.
*/
export type ThinkingEffort = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
export type ThinkingEffort = 'off' | 'on' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
/**
* Optional context passed to {@link ChatProvider.withMaxCompletionTokens} so a

View file

@ -330,7 +330,17 @@ function supportsEffortParam(model: string, adaptive: boolean): boolean {
return normalized.includes('opus-4-5') || normalized.includes('opus-4.5');
}
function clampEffort(effort: ThinkingEffort, model: string, adaptive: boolean): ThinkingEffort {
type AnthropicThinkingEffort = Exclude<ThinkingEffort, 'on'>;
function normalizeAnthropicEffort(effort: ThinkingEffort): AnthropicThinkingEffort {
return effort === 'on' ? 'high' : effort;
}
function clampEffort(
effort: AnthropicThinkingEffort,
model: string,
adaptive: boolean,
): AnthropicThinkingEffort {
if (effort === 'off') {
return effort;
}
@ -343,7 +353,7 @@ function clampEffort(effort: ThinkingEffort, model: string, adaptive: boolean):
return effort;
}
function budgetTokensForEffort(effort: ThinkingEffort): number {
function budgetTokensForEffort(effort: AnthropicThinkingEffort): number {
switch (effort) {
case 'low':
return 1024;
@ -1222,7 +1232,7 @@ export class AnthropicChatProvider implements ChatProvider {
return clone;
}
const effectiveEffort = clampEffort(effort, this._model, adaptive);
const effectiveEffort = clampEffort(normalizeAnthropicEffort(effort), this._model, adaptive);
if (effectiveEffort === 'off') {
throw new Error('Non-off thinking effort unexpectedly clamped to off.');
}

View file

@ -27,7 +27,6 @@ import {
normalizeOpenAIFinishReason,
type OpenAIContentPart,
type OpenAIToolParam,
reasoningEffortToThinkingEffort,
toolToOpenAI,
} from './openai-common';
import {
@ -47,6 +46,7 @@ export interface KimiOptions {
stream?: boolean | undefined;
defaultHeaders?: Record<string, string> | undefined;
generationKwargs?: GenerationKwargs | undefined;
supportEfforts?: readonly string[];
clientFactory?: (auth: ProviderRequestAuth) => OpenAI;
}
@ -365,6 +365,7 @@ export class KimiChatProvider implements ChatProvider {
private _baseUrl: string;
private _defaultHeaders: Record<string, string> | undefined;
private _generationKwargs: GenerationKwargs;
private readonly _supportEfforts: readonly string[];
private _client: OpenAI | undefined;
private _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined;
private _files: KimiFiles | undefined;
@ -378,6 +379,7 @@ export class KimiChatProvider implements ChatProvider {
this._model = options.model;
this._stream = options.stream ?? true;
this._generationKwargs = { ...options.generationKwargs };
this._supportEfforts = options.supportEfforts ?? [];
this._client =
this._apiKey === undefined
? undefined
@ -414,7 +416,11 @@ export class KimiChatProvider implements ChatProvider {
}
get thinkingEffort(): ThinkingEffort | null {
return reasoningEffortToThinkingEffort(this._generationKwargs.reasoning_effort);
const thinking = this._generationKwargs.extra_body?.thinking;
if (thinking === undefined) return null;
if (thinking.type === 'disabled') return 'off';
const effort = thinking['effort'];
return typeof effort === 'string' ? (effort as ThinkingEffort) : 'on';
}
get modelParameters(): Record<string, unknown> {
@ -500,28 +506,21 @@ export class KimiChatProvider implements ChatProvider {
}
withThinking(effort: ThinkingEffort): KimiChatProvider {
const thinking: ThinkingConfig = {
type: effort === 'off' ? 'disabled' : 'enabled',
};
let reasoningEffort: string | undefined;
switch (effort) {
case 'off':
reasoningEffort = undefined;
break;
case 'low':
reasoningEffort = 'low';
break;
case 'medium':
reasoningEffort = 'medium';
break;
case 'high':
case 'xhigh':
case 'max':
reasoningEffort = 'high';
break;
let thinking: ThinkingConfig;
if (effort === 'off') {
thinking = { type: 'disabled' };
} else {
thinking = this._supportEfforts.includes(effort)
? { type: 'enabled', effort }
: { type: 'enabled' };
}
return this._withGenerationKwargs({ reasoning_effort: reasoningEffort }).withExtraBody({
thinking,
const oldExtra = this._generationKwargs.extra_body ?? {};
const keep = oldExtra.thinking?.keep;
if (keep !== undefined) {
thinking = { ...thinking, keep };
}
return this._withGenerationKwargs({
extra_body: { ...oldExtra, thinking },
});
}

View file

@ -23,7 +23,15 @@ export const modelsFromToml = (rawSnake: unknown): unknown => {
if (!isPlainObject(rawSnake)) return rawSnake;
const out: Record<string, unknown> = {};
for (const [alias, entry] of Object.entries(rawSnake)) {
out[alias] = isPlainObject(entry) ? transformPlainObject(entry) : entry;
if (!isPlainObject(entry)) {
out[alias] = entry;
continue;
}
const converted = transformPlainObject(entry);
if (isPlainObject(converted['overrides'])) {
converted['overrides'] = transformPlainObject(converted['overrides']);
}
out[alias] = converted;
}
return out;
};
@ -43,6 +51,8 @@ export const modelsToToml = (value: unknown, rawSnake: unknown): unknown => {
for (const [key, field] of Object.entries(entry)) {
if (key === 'capabilities' && Array.isArray(field)) {
converted[camelToSnake(key)] = [...field];
} else if (key === 'overrides' && isPlainObject(field)) {
converted['overrides'] = modelOverridesToToml(field, rawEntry['overrides']);
} else {
setDefined(converted, camelToSnake(key), field);
}
@ -50,10 +60,25 @@ export const modelsToToml = (value: unknown, rawSnake: unknown): unknown => {
out[alias] = { ...rawEntry, ...converted };
}
return out;
};
function modelOverridesToToml(
overrides: Record<string, unknown>,
rawSnake: unknown,
): Record<string, unknown> {
const out = cloneRecord(rawSnake);
for (const [key, value] of Object.entries(overrides)) {
if (key === 'capabilities' && Array.isArray(value)) {
out[camelToSnake(key)] = [...value];
} else {
setDefined(out, camelToSnake(key), value);
}
}
return out;
}
registerConfigSection(MODELS_SECTION, ModelsSectionSchema, {
defaultValue: {},
fromToml: modelsFromToml,
toToml: modelsToToml,
});;
});

View file

@ -20,3 +20,4 @@ export * from './modelOverrides';
export * from './modelResolver';
export * from './modelResolverService';
export * from './modelService';
export * from './thinking';

View file

@ -33,42 +33,60 @@ import { ProtocolSchema } from '#/app/protocol';
export const MODELS_SECTION = 'models';
export const ModelSchema = z
.object({
// Structured path — reference a Provider (which references a Platform).
providerId: z.string().optional(),
const ModelBaseSchema = z.object({
// Structured path — reference a Provider (which references a Platform).
providerId: z.string().optional(),
// Flat path — inline endpoint + optional inline auth overrides. When
// providerId is absent, the resolver synthesizes a Provider from the
// baseUrl origin. When both are present, providerId wins and baseUrl
// acts as a per-Model override.
baseUrl: z.string().optional(),
apiKey: z.string().optional(),
oauth: OAuthRefSchema.optional(),
// Flat path — inline endpoint + optional inline auth overrides. When
// providerId is absent, the resolver synthesizes a Provider from the
// baseUrl origin. When both are present, providerId wins and baseUrl
// acts as a per-Model override.
baseUrl: z.string().optional(),
apiKey: z.string().optional(),
oauth: OAuthRefSchema.optional(),
// Wire protocol. Every Model declares exactly one; if the same physical
// model is served over two protocols (e.g. Anthropic direct + OpenAI-
// compat), that is two Model entries with different ids and a shared
// `name` (via `aliases`).
protocol: ProtocolSchema.optional(),
// Wire protocol. Every Model declares exactly one; if the same physical
// model is served over two protocols (e.g. Anthropic direct + OpenAI-
// compat), that is two Model entries with different ids and a shared
// `name` (via `aliases`).
protocol: ProtocolSchema.optional(),
// Wire-facing model identifier and routing aliases.
name: z.string().optional(),
aliases: z.array(z.string()).optional(),
// Wire-facing model identifier and routing aliases.
name: z.string().optional(),
aliases: z.array(z.string()).optional(),
// Existing capability / budget knobs — carried forward unchanged so
// legacy configs continue to load. Phase 4 migration lifts the old
// `provider`+`model` pair into the new `providerId`+`name` shape.
provider: z.string().optional(),
model: z.string().optional(),
maxContextSize: z.number().int().min(1).optional(),
maxOutputSize: z.number().int().min(1).optional(),
capabilities: z.array(z.string()).optional(),
displayName: z.string().optional(),
reasoningKey: z.string().optional(),
adaptiveThinking: z.boolean().optional(),
})
.passthrough();
// Existing capability / budget knobs — carried forward unchanged so
// legacy configs continue to load. Phase 4 migration lifts the old
// `provider`+`model` pair into the new `providerId`+`name` shape.
provider: z.string().optional(),
model: z.string().optional(),
maxContextSize: z.number().int().min(1).optional(),
maxOutputSize: z.number().int().min(1).optional(),
capabilities: z.array(z.string()).optional(),
displayName: z.string().optional(),
reasoningKey: z.string().optional(),
adaptiveThinking: z.boolean().optional(),
betaApi: z.boolean().optional(),
supportEfforts: z.array(z.string()).optional(),
defaultEffort: z.string().optional(),
});
export const ModelOverrideSchema = ModelBaseSchema.omit({
providerId: true,
baseUrl: true,
apiKey: true,
oauth: true,
protocol: true,
name: true,
aliases: true,
provider: true,
model: true,
betaApi: true,
}).partial();
export const ModelSchema = ModelBaseSchema.extend({
overrides: ModelOverrideSchema.optional(),
}).passthrough();
export type ModelConfig = z.infer<typeof ModelSchema>;

View file

@ -18,20 +18,22 @@
*/
import { AsyncEventQueue } from '#/_base/asyncEventQueue';
import type {
GenerateCallbacks,
GenerationKwargs,
MaxCompletionTokensOptions,
ModelCapability,
ProviderRequestAuth,
StreamDecodeStats,
ThinkingEffort,
VideoUploadInput,
VideoURLPart,
import {
APIStatusError,
type GenerateCallbacks,
type GenerationKwargs,
type MaxCompletionTokensOptions,
type ModelCapability,
type ProviderRequestAuth,
type StreamDecodeStats,
type ThinkingEffort,
type VideoUploadInput,
type VideoURLPart,
} from '#/app/llmProtocol';
import type { ChatProvider, Protocol } from '#/app/protocol';
import type { ChatProvider, Protocol, ProtocolProviderOptions } from '#/app/protocol';
import { generate } from '#/app/protocol';
import { type ProtocolAdapterRegistry } from '#/app/protocol/protocolAdapterRegistry';
import { ErrorCodes, KimiError } from '#/errors';
import type { AuthProvider, LLMEvent, LLMRequestInput, Model } from './modelInstance';
@ -47,12 +49,13 @@ export interface ModelImplInit {
readonly maxOutputSize?: number;
readonly displayName?: string;
readonly reasoningKey?: string;
readonly supportEfforts?: readonly string[];
readonly defaultEffort?: string;
readonly alwaysThinking: boolean;
readonly providerName: string;
readonly authProvider: AuthProvider;
readonly protocolRegistry: ProtocolAdapterRegistry;
/** Extra kosong-shaped config passed through when constructing the wire adapter. */
readonly extras?: Readonly<Record<string, unknown>>;
readonly providerOptions?: ProtocolProviderOptions;
}
export class ModelImpl implements Model {
@ -67,13 +70,15 @@ export class ModelImpl implements Model {
readonly maxOutputSize?: number;
readonly displayName?: string;
readonly reasoningKey?: string;
readonly supportEfforts?: readonly string[];
readonly defaultEffort?: string;
readonly authProvider: AuthProvider;
readonly thinkingEffort: ThinkingEffort | null;
readonly alwaysThinking: boolean;
readonly providerName: string;
private readonly protocolRegistry: ProtocolAdapterRegistry;
private readonly extras: Readonly<Record<string, unknown>>;
private readonly providerOptions: ProtocolProviderOptions;
/**
* Chain of transforms applied to the raw kosong `ChatProvider` before use.
@ -96,9 +101,11 @@ export class ModelImpl implements Model {
this.maxOutputSize = init.maxOutputSize;
this.displayName = init.displayName;
this.reasoningKey = init.reasoningKey;
this.supportEfforts = init.supportEfforts;
this.defaultEffort = init.defaultEffort;
this.authProvider = init.authProvider;
this.protocolRegistry = init.protocolRegistry;
this.extras = init.extras ?? {};
this.providerOptions = init.providerOptions ?? {};
this.transforms = transforms;
this.alwaysThinking = init.alwaysThinking;
this.providerName = init.providerName;
@ -109,8 +116,9 @@ export class ModelImpl implements Model {
}
private clone(
transform: (p: ChatProvider) => ChatProvider,
transform: ((p: ChatProvider) => ChatProvider) | undefined,
fieldOverride?: Partial<ModelImpl>,
initOverride?: { readonly providerOptions?: ProtocolProviderOptions },
): Model {
const next = new ModelImpl(
{
@ -125,13 +133,15 @@ export class ModelImpl implements Model {
maxOutputSize: this.maxOutputSize,
displayName: this.displayName,
reasoningKey: this.reasoningKey,
supportEfforts: this.supportEfforts,
defaultEffort: this.defaultEffort,
alwaysThinking: this.alwaysThinking,
providerName: this.providerName,
authProvider: this.authProvider,
protocolRegistry: this.protocolRegistry,
extras: this.extras,
providerOptions: initOverride?.providerOptions ?? this.providerOptions,
},
[...this.transforms, transform],
transform === undefined ? this.transforms : [...this.transforms, transform],
);
if (fieldOverride !== undefined) {
Object.assign(next, fieldOverride);
@ -158,6 +168,12 @@ export class ModelImpl implements Model {
});
}
withProviderOptions(options: ProtocolProviderOptions): Model {
return this.clone(undefined, undefined, {
providerOptions: mergeProviderOptions(this.providerOptions, options),
});
}
/** Materialize the transformed kosong ChatProvider. Cached per Model instance. */
private resolveChatProvider(): ChatProvider {
if (this.cachedChatProvider !== undefined) return this.cachedChatProvider;
@ -166,7 +182,7 @@ export class ModelImpl implements Model {
baseUrl: this.baseUrl,
modelName: this.name,
defaultHeaders: this.headers,
extras: this.extras,
providerOptions: this.providerOptions,
});
for (const transform of this.transforms) provider = transform(provider);
this.cachedChatProvider = provider;
@ -192,7 +208,10 @@ export class ModelImpl implements Model {
`Model "${this.id}" (protocol=${this.protocol}) does not support video upload`,
);
}
return provider.uploadVideo(input, { signal: options?.signal });
const uploadVideo = provider.uploadVideo;
return this.runWithAuthRefresh((auth) =>
uploadVideo.call(provider, input, { signal: options?.signal, auth }),
);
}
private async runRequest(
@ -218,30 +237,30 @@ export class ModelImpl implements Model {
},
};
const auth = await this.authProvider.getAuth();
requestStartedAt = Date.now();
const result = await generate(
provider,
input.systemPrompt,
[...input.tools],
[...input.messages],
callbacks,
{
signal,
auth,
onRequestStart: () => {
requestStartedAt = Date.now();
const result = await this.runWithAuthRefresh((auth) => {
requestStartedAt = Date.now();
return generate(
provider,
input.systemPrompt,
[...input.tools],
[...input.messages],
callbacks,
{
signal,
auth,
onRequestStart: () => {
requestStartedAt = Date.now();
},
onRequestSent: () => {
requestSentAt = Date.now();
},
onStreamEnd: (stats) => {
streamEndedAt = Date.now();
decodeStats = stats;
},
},
onRequestSent: () => {
requestSentAt = Date.now();
},
onStreamEnd: (stats) => {
streamEndedAt = Date.now();
decodeStats = stats;
},
},
);
);
});
// Non-streaming providers still populate `result.message`; surface its
// content and tool calls as parts so downstream consumers see them.
@ -279,6 +298,61 @@ export class ModelImpl implements Model {
});
}
}
private async runWithAuthRefresh<T>(
run: (auth: ProviderRequestAuth | undefined) => Promise<T>,
): Promise<T> {
const auth = await this.authProvider.getAuth();
try {
return await run(auth);
} catch (error) {
if (!this.shouldForceRefresh(error)) throw error;
}
const refreshedAuth = await this.authProvider.getAuth({ force: true });
try {
return await run(refreshedAuth);
} catch (error) {
if (isUnauthorizedStatusError(error)) throw toLoginRequiredError(error);
throw error;
}
}
private shouldForceRefresh(error: unknown): boolean {
return this.authProvider.canRefresh === true && isUnauthorizedStatusError(error);
}
}
function isUnauthorizedStatusError(error: unknown): error is APIStatusError {
return error instanceof APIStatusError && error.statusCode === 401;
}
function toLoginRequiredError(error: APIStatusError): KimiError {
return new KimiError(
ErrorCodes.AUTH_LOGIN_REQUIRED,
'OAuth provider credentials were rejected. Send /login to login.',
{
cause: error,
details: {
statusCode: error.statusCode,
requestId: error.requestId,
},
},
);
}
function mergeProviderOptions(
base: ProtocolProviderOptions,
next: ProtocolProviderOptions,
): ProtocolProviderOptions {
return {
...base,
...next,
metadata:
base.metadata === undefined && next.metadata === undefined
? undefined
: { ...base.metadata, ...next.metadata },
};
}
export function buildStreamTiming(
@ -325,6 +399,8 @@ export function buildStreamTiming(
* refresh semantics.
*/
export class StaticAuthProvider implements AuthProvider {
readonly canRefresh = false;
constructor(private readonly apiKey: string | undefined) {}
async getAuth(): Promise<ProviderRequestAuth | undefined> {
if (this.apiKey === undefined || this.apiKey.trim().length === 0) return undefined;

View file

@ -31,7 +31,7 @@ import type {
VideoUploadInput,
VideoURLPart,
} from '#/app/llmProtocol';
import type { Protocol } from '#/app/protocol';
import type { Protocol, ProtocolProviderOptions } from '#/app/protocol';
/**
* Closure that produces a fresh `ProviderRequestAuth` on demand. Wraps an
@ -39,6 +39,9 @@ import type { Protocol } from '#/app/protocol';
* Reading it always returns the current material callers must not cache.
*/
export interface AuthProvider {
/** Whether this auth source can force-refresh credentials after an upstream 401. */
readonly canRefresh?: boolean;
/**
* Get a `ProviderRequestAuth` for the next request. Returns `undefined`
* when no auth material is available (anonymous endpoint, or the caller
@ -96,6 +99,8 @@ export interface Model {
readonly maxOutputSize?: number;
readonly displayName?: string;
readonly reasoningKey?: string;
readonly supportEfforts?: readonly string[];
readonly defaultEffort?: string;
readonly thinkingEffort: ThinkingEffort | null;
/**
* True when this Model's capabilities include `always_thinking` the
@ -125,6 +130,9 @@ export interface Model {
/** Return a new Model wrapper with additional generation kwargs applied. */
withGenerationKwargs(kwargs: GenerationKwargs): Model;
/** Return a new Model wrapper with additional protocol-constructor options applied. */
withProviderOptions(options: ProtocolProviderOptions): Model;
/**
* Drive one LLM request end-to-end. Streams `LLMEvent`s until the stream
* terminates (either normally with `usage`+`finish`, or with an error).

View file

@ -30,7 +30,11 @@ import { getModelCapability } from '#/app/llmProtocol/providers';
import { IPlatformService, UNKNOWN_PLATFORM_KEY } from '#/app/platform';
import type { OAuthRef, ProviderConfig } from '#/app/provider';
import { IProviderService } from '#/app/provider';
import { IProtocolAdapterRegistry, type Protocol } from '#/app/protocol';
import {
IProtocolAdapterRegistry,
type Protocol,
type ProtocolProviderOptions,
} from '#/app/protocol';
import { type ProtocolAdapterRegistry } from '#/app/protocol/protocolAdapterRegistry';
import type { ModelConfig } from './model';
@ -38,16 +42,7 @@ import { IModelService } from './model';
import type { AuthProvider, Model } from './modelInstance';
import { IModelResolver } from './modelResolver';
import { ModelImpl, StaticAuthProvider } from './modelImpl';
/**
* Default thinking effort applied when the user has not disabled thinking
* (matches `profile`'s `DEFAULT_THINKING_EFFORT`). Read here rather than
* imported so `model` (L2) does not depend on `profile` (L4); the source of
* truth for the value is the `thinking` / `defaultThinking` config sections,
* which are shared via `IConfigService`.
*/
const DEFAULT_THINKING_EFFORT: ThinkingEffort = 'high';
const THINKING_EFFORTS: readonly ThinkingEffort[] = ['low', 'medium', 'high', 'xhigh', 'max'];
import { resolveThinkingEffortForModel } from './thinking';
/** Shape of the `thinking` config section (owned by `profile`); only the
* fields the resolver needs to mirror the production default are read here. */
@ -62,6 +57,10 @@ interface ResolvedAuthMaterial {
readonly oauthProviderKey?: string;
}
type MutableProtocolProviderOptions = {
-readonly [K in keyof ProtocolProviderOptions]: ProtocolProviderOptions[K];
};
export class ModelResolverService extends Disposable implements IModelResolver {
declare readonly _serviceBrand: undefined;
@ -78,24 +77,25 @@ export class ModelResolverService extends Disposable implements IModelResolver {
}
resolve(id: string): Model {
const model = this.models.get(id);
if (model === undefined) {
const configuredModel = this.models.get(id);
if (configuredModel === undefined) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
`Model "${id}" is not configured in config.toml.`,
);
}
const model = effectiveModelConfig(configuredModel);
const { providerConfig, providerName, resolvedBaseUrl: rawBaseUrl } = this.resolveProviderContext(id, model);
const auth = this.resolveAuth(model, providerConfig);
const auth = this.resolveAuth(id, model, providerConfig, providerName);
const authProvider = this.buildAuthProvider(providerName, auth);
const protocol = this.resolveProtocol(id, model, providerConfig);
// The Anthropic SDK appends `/v1/messages` to the baseUrl, so a provider
// whose baseUrl already ends in `/v1` (e.g. the managed Kimi endpoint) would
// otherwise produce a double `/v1/v1/messages` → 404. Match production v1
// (`provider-manager` strips a trailing `/v1` for the anthropic transport).
const resolvedBaseUrl = protocol === 'anthropic' ? stripTrailingV1(rawBaseUrl) : rawBaseUrl;
// Match production v1: strip a trailing `/v1` only when the model explicitly
// overrides into the Anthropic transport. Native Anthropic providers keep
// their configured `/v1` because the old provider manager did too.
const resolvedBaseUrl =
model.protocol === 'anthropic' ? stripTrailingV1(rawBaseUrl) : rawBaseUrl;
const wireName = model.name ?? model.model;
if (wireName === undefined) {
throw new KimiError(
@ -116,6 +116,12 @@ export class ModelResolverService extends Disposable implements IModelResolver {
wireName,
model.maxContextSize,
);
const providerOptions = buildProtocolProviderOptions(
model,
protocol,
providerConfig,
resolvedBaseUrl,
);
const declared = new Set((model.capabilities ?? []).map((c) => c.trim().toLowerCase()));
const alwaysThinking = declared.has('always_thinking');
@ -131,11 +137,13 @@ export class ModelResolverService extends Disposable implements IModelResolver {
maxOutputSize: model.maxOutputSize,
displayName: model.displayName,
reasoningKey: model.reasoningKey,
supportEfforts: model.supportEfforts,
defaultEffort: model.defaultEffort,
alwaysThinking,
providerName,
authProvider,
protocolRegistry: this.protocolRegistry as ProtocolAdapterRegistry,
extras: buildProviderExtras(model),
providerOptions,
});
// Apply the production default thinking effort so a plain `model.request()`
@ -143,7 +151,7 @@ export class ModelResolverService extends Disposable implements IModelResolver {
// same `thinking` / `defaultThinking` config). Required for models whose
// endpoint rejects a request that omits thinking (e.g. kimi-k2.7 over the
// Anthropic protocol returns 400 unless `thinking.type === 'enabled'`).
const effort = this.resolveDefaultThinking(alwaysThinking);
const effort = this.resolveDefaultThinking(model, alwaysThinking);
return effort === 'off' ? impl : impl.withThinking(effort);
}
@ -152,18 +160,25 @@ export class ModelResolverService extends Disposable implements IModelResolver {
* god-object's default matches the production agent path:
* - an explicit `defaultThinking === false` or `thinking.mode === 'off'`
* turns thinking off;
* - otherwise the configured `thinking.effort` (default 'high') is used;
* - otherwise the configured `thinking.effort` is used, falling back to the
* model's declared default effort / middle supported effort / boolean `on`;
* - an `always_thinking` model clamps an explicit "off" back to on.
*/
private resolveDefaultThinking(alwaysThinking: boolean): ThinkingEffort {
private resolveDefaultThinking(
model: ModelConfig,
alwaysThinking: boolean,
): ThinkingEffort {
const defaultThinking = this.config.get<boolean | undefined>('defaultThinking');
const thinking = this.config.get<ThinkingSection | undefined>('thinking');
const turnedOff = defaultThinking === false || thinking?.mode === 'off';
const configured = parseThinkingEffort(thinking?.effort) ?? DEFAULT_THINKING_EFFORT;
if (turnedOff && !alwaysThinking) {
return 'off';
}
return configured;
return resolveThinkingEffortForModel(
undefined,
{
defaultThinking,
mode: thinking?.mode,
effort: thinking?.effort,
},
{ ...model, alwaysThinking },
);
}
findByName(name: string): readonly string[] {
@ -202,7 +217,13 @@ export class ModelResolverService extends Disposable implements IModelResolver {
`Provider "${providerId}" referenced by model "${id}" is not configured.`,
);
}
const baseUrl = model.baseUrl ?? providerConfig.baseUrl;
const baseUrl =
nonEmpty(model.baseUrl) ??
nonEmpty(providerConfig.baseUrl) ??
providerBaseUrlEnvFallback(
model.protocol ?? (providerConfig.type as Protocol | undefined),
providerConfig.env,
);
if (baseUrl === undefined || baseUrl.length === 0) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
@ -214,17 +235,18 @@ export class ModelResolverService extends Disposable implements IModelResolver {
// Flat path — Model carries its own baseUrl. Synthesize a Provider id
// from the URL's origin so two flat Models on the same host converge.
if (model.baseUrl === undefined || model.baseUrl.length === 0) {
const modelBaseUrl = nonEmpty(model.baseUrl);
if (modelBaseUrl === undefined) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
`Model "${id}" must set either providerId or baseUrl in config.toml.`,
);
}
const originName = deriveProviderId(model.baseUrl);
const originName = deriveProviderId(modelBaseUrl);
return {
providerConfig: undefined,
providerName: originName,
resolvedBaseUrl: model.baseUrl,
resolvedBaseUrl: modelBaseUrl,
};
}
@ -255,10 +277,15 @@ export class ModelResolverService extends Disposable implements IModelResolver {
* empty bearer token.
*/
private resolveAuth(
id: string,
model: ModelConfig,
provider: ProviderConfig | undefined,
providerName: string,
): ResolvedAuthMaterial {
const modelApiKey = nonEmpty(model.apiKey);
if (modelApiKey !== undefined && model.oauth !== undefined) {
throw authConflictError('Model', id);
}
if (modelApiKey !== undefined) return { apiKey: modelApiKey };
if (model.oauth !== undefined) {
return { oauth: model.oauth, oauthProviderKey: model.providerId ?? model.provider };
@ -267,7 +294,13 @@ export class ModelResolverService extends Disposable implements IModelResolver {
const platformId = provider?.platformId;
if (platformId !== undefined && platformId !== UNKNOWN_PLATFORM_KEY) {
const platform = this.platforms.get(platformId);
const platformApiKey = nonEmpty(platform?.auth?.apiKey);
const authType = provider?.type ?? model.protocol;
const platformApiKey =
nonEmpty(platform?.auth?.apiKey) ??
providerApiKeyEnvFallback(authType, platform?.auth?.env);
if (platformApiKey !== undefined && platform?.auth?.oauth !== undefined) {
throw authConflictError('Platform', platformId);
}
if (platformApiKey !== undefined) return { apiKey: platformApiKey };
if (platform?.auth?.oauth !== undefined) {
return {
@ -278,7 +311,12 @@ export class ModelResolverService extends Disposable implements IModelResolver {
}
// Legacy: provider carried auth directly (pre-Phase 4 migration).
const providerApiKey = nonEmpty(provider?.apiKey);
const providerApiKey =
nonEmpty(provider?.apiKey) ??
providerApiKeyEnvFallback(provider?.type ?? model.protocol, provider?.env);
if (providerApiKey !== undefined && provider?.oauth !== undefined) {
throw authConflictError('Provider', providerName);
}
if (providerApiKey !== undefined) return { apiKey: providerApiKey };
if (provider?.oauth !== undefined) {
return { oauth: provider.oauth, oauthProviderKey: model.providerId ?? model.provider };
@ -294,12 +332,19 @@ export class ModelResolverService extends Disposable implements IModelResolver {
const oauthRef = auth.oauth;
const providerKey = auth.oauthProviderKey ?? providerName;
const oauthService = this.oauth;
const loginRequired = (cause?: unknown): KimiError =>
new KimiError(
ErrorCodes.AUTH_LOGIN_REQUIRED,
`OAuth provider "${providerKey}" requires login before it can be used.`,
cause === undefined ? undefined : { cause },
);
return {
canRefresh: true,
async getAuth(options): Promise<ProviderRequestAuth | undefined> {
const tokenProvider = oauthService.resolveTokenProvider(providerKey, oauthRef);
if (tokenProvider === undefined) return undefined;
if (tokenProvider === undefined) throw loginRequired();
const apiKey = await tokenProvider.getAccessToken({ force: options?.force ?? false });
if (apiKey.trim().length === 0) return undefined;
if (apiKey.trim().length === 0) throw loginRequired();
return { apiKey };
},
};
@ -308,13 +353,6 @@ export class ModelResolverService extends Disposable implements IModelResolver {
}
}
function parseThinkingEffort(value: string | undefined): ThinkingEffort | undefined {
const normalized = value?.trim().toLowerCase();
return normalized !== undefined && (THINKING_EFFORTS as readonly string[]).includes(normalized)
? (normalized as ThinkingEffort)
: undefined;
}
function resolveModelCapabilities(
declaredCapabilities: readonly string[] | undefined,
protocol: Protocol,
@ -347,20 +385,145 @@ function stripTrailingV1(baseUrl: string): string {
return baseUrl.replace(/\/v1\/?$/, '');
}
/** Provider knobs the wire adapter needs that aren't first-class ModelImpl
* fields. `adaptiveThinking` changes how the Anthropic adapter encodes the
* thinking param, so it must reach the provider for the default-thinking
* transform to produce the right shape on adaptive models. */
function buildProviderExtras(model: ModelConfig): Readonly<Record<string, unknown>> | undefined {
const extras: Record<string, unknown> = {};
if (model.adaptiveThinking !== undefined) {
extras['adaptiveThinking'] = model.adaptiveThinking;
function effectiveModelConfig(model: ModelConfig): ModelConfig {
const { overrides, ...base } = model;
if (overrides === undefined) return model;
const effective: ModelConfig = { ...base, ...overrides };
if (
overrides.supportEfforts !== undefined &&
overrides.defaultEffort === undefined &&
effective.defaultEffort !== undefined &&
!overrides.supportEfforts.includes(effective.defaultEffort)
) {
delete effective.defaultEffort;
}
const betaApi = (model as Record<string, unknown>)['betaApi'];
if (betaApi !== undefined) {
extras['betaApi'] = betaApi;
return effective;
}
function authConflictError(kind: string, name: string): KimiError {
return new KimiError(
ErrorCodes.CONFIG_INVALID,
`${kind} "${name}" has both apiKey and oauth set in config.toml - they are mutually exclusive. Remove one.`,
);
}
function buildProtocolProviderOptions(
model: ModelConfig,
protocol: Protocol,
provider: ProviderConfig | undefined,
baseUrl: string,
): ProtocolProviderOptions | undefined {
const options: MutableProtocolProviderOptions = {};
switch (protocol) {
case 'anthropic':
if (model.maxOutputSize !== undefined) options.defaultMaxTokens = model.maxOutputSize;
if (model.adaptiveThinking !== undefined) options.adaptiveThinking = model.adaptiveThinking;
if (model.betaApi !== undefined) options.betaApi = model.betaApi;
break;
case 'openai': {
const reasoningKey = nonEmpty(model.reasoningKey);
if (reasoningKey !== undefined) options.reasoningKey = reasoningKey;
break;
}
case 'kimi':
if (model.supportEfforts !== undefined) options.supportEfforts = model.supportEfforts;
break;
case 'vertexai': {
const project = vertexAIProject(provider);
const location = vertexAILocation(provider, baseUrl);
options.vertexai = project !== undefined && location !== undefined;
if (project !== undefined) options.project = project;
if (location !== undefined) options.location = location;
break;
}
case 'google-genai':
case 'openai_responses':
break;
default: {
const exhaustive: never = protocol;
void exhaustive;
}
}
return Object.values(options).some((value) => value !== undefined)
? options
: undefined;
}
function providerBaseUrlEnvFallback(
protocol: Protocol | undefined,
env: Record<string, string> | undefined,
): string | undefined {
if (protocol === undefined) return undefined;
switch (protocol) {
case 'anthropic':
return envValue(env, 'ANTHROPIC_BASE_URL');
case 'openai':
case 'openai_responses':
return envValue(env, 'OPENAI_BASE_URL');
case 'kimi':
return envValue(env, 'KIMI_BASE_URL');
case 'google-genai':
return envValue(env, 'GOOGLE_GEMINI_BASE_URL');
case 'vertexai':
return envValue(env, 'GOOGLE_VERTEX_BASE_URL');
default: {
const exhaustive: never = protocol;
return exhaustive;
}
}
}
function providerApiKeyEnvFallback(
protocol: Protocol | undefined,
env: Record<string, string> | undefined,
): string | undefined {
if (protocol === undefined) return undefined;
switch (protocol) {
case 'anthropic':
return envValue(env, 'ANTHROPIC_API_KEY');
case 'openai':
case 'openai_responses':
return envValue(env, 'OPENAI_API_KEY');
case 'kimi':
return envValue(env, 'KIMI_API_KEY');
case 'google-genai':
return envValue(env, 'GOOGLE_API_KEY');
case 'vertexai':
return envValue(env, 'VERTEXAI_API_KEY') ?? envValue(env, 'GOOGLE_API_KEY');
default: {
const exhaustive: never = protocol;
return exhaustive;
}
}
}
function vertexAIProject(provider: ProviderConfig | undefined): string | undefined {
return envValue(provider?.env, 'GOOGLE_CLOUD_PROJECT');
}
function vertexAILocation(
provider: ProviderConfig | undefined,
baseUrl: string | undefined,
): string | undefined {
return envValue(provider?.env, 'GOOGLE_CLOUD_LOCATION') ?? locationFromVertexAIBaseUrl(baseUrl);
}
function envValue(env: Record<string, string> | undefined, key: string): string | undefined {
return nonEmpty(env?.[key]);
}
function locationFromVertexAIBaseUrl(baseUrl: string | undefined): string | undefined {
const url = nonEmpty(baseUrl);
if (url === undefined) return undefined;
try {
const host = new URL(url).hostname;
const suffix = '-aiplatform.googleapis.com';
return host.endsWith(suffix) ? nonEmpty(host.slice(0, -suffix.length)) : undefined;
} catch {
return undefined;
}
return Object.keys(extras).length > 0 ? extras : undefined;
}
/**

View file

@ -0,0 +1,111 @@
/**
* `model` domain (L2) model-aware thinking effort resolution.
*
* Resolves the effective thinking effort from request/config defaults plus the
* model's declared thinking metadata. Shared by `modelResolver` and the
* Agent-scope `profile` domain so both paths keep v1-compatible defaults.
*/
import type { ModelCapability, ThinkingEffort } from '#/app/llmProtocol';
export interface ThinkingDefaults {
readonly defaultThinking?: boolean;
readonly mode?: string;
readonly effort?: string;
}
export interface ModelThinkingMetadata {
readonly capabilities?: ModelCapability | readonly string[];
readonly adaptiveThinking?: boolean;
readonly alwaysThinking?: boolean;
readonly supportEfforts?: readonly string[];
readonly defaultEffort?: string;
}
function nonEmpty(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
}
function hasCapability(
capabilities: ModelThinkingMetadata['capabilities'],
capability: string,
): boolean {
if (capabilities === undefined) return false;
if (isCapabilityList(capabilities)) {
return capabilities.some((candidate) => candidate.trim().toLowerCase() === capability);
}
switch (capability) {
case 'thinking':
return capabilities.thinking;
case 'always_thinking':
return false;
default:
return false;
}
}
function isCapabilityList(
capabilities: ModelThinkingMetadata['capabilities'],
): capabilities is readonly string[] {
return Array.isArray(capabilities);
}
function middleOf(values: readonly string[]): string {
return values[Math.floor(values.length / 2)]!;
}
export function modelSupportsThinking(model: ModelThinkingMetadata | undefined): boolean {
if (model === undefined) return false;
return (
model.alwaysThinking === true ||
model.adaptiveThinking === true ||
hasCapability(model.capabilities, 'thinking') ||
hasCapability(model.capabilities, 'always_thinking')
);
}
export function defaultThinkingEffortForModel(
model: ModelThinkingMetadata | undefined,
): ThinkingEffort {
if (model === undefined || !modelSupportsThinking(model)) return 'off';
const efforts = model.supportEfforts?.map(nonEmpty).filter((v): v is string => v !== undefined);
if (efforts !== undefined && efforts.length > 0) {
return (nonEmpty(model.defaultEffort) ?? middleOf(efforts)) as ThinkingEffort;
}
return 'on';
}
function enabledThinkingEffortForModel(
model: ModelThinkingMetadata | undefined,
): ThinkingEffort {
const effort = defaultThinkingEffortForModel(model);
return effort === 'off' ? 'on' : effort;
}
export function resolveThinkingEffortForModel(
requested: string | undefined,
defaults: ThinkingDefaults | undefined,
model: ModelThinkingMetadata | undefined,
): ThinkingEffort {
const configured = nonEmpty(defaults?.effort) as ThinkingEffort | undefined;
const normalized = nonEmpty(requested)?.toLowerCase();
let effort: ThinkingEffort;
if (normalized !== undefined) {
effort =
normalized === 'on'
? configured ?? enabledThinkingEffortForModel(model)
: (normalized as ThinkingEffort);
} else if (defaults?.mode === 'on') {
effort = configured ?? enabledThinkingEffortForModel(model);
} else if (defaults?.defaultThinking === false || defaults?.mode === 'off') {
effort = 'off';
} else {
effort = configured ?? defaultThinkingEffortForModel(model);
}
if (effort === 'off' && model?.alwaysThinking === true) {
return configured ?? defaultThinkingEffortForModel(model);
}
return effort;
}

View file

@ -30,11 +30,24 @@ export const ProtocolSchema = z.enum([
export type Protocol = z.infer<typeof ProtocolSchema>;
export interface ProtocolProviderOptions {
readonly reasoningKey?: string;
readonly defaultMaxTokens?: number;
readonly adaptiveThinking?: boolean;
readonly betaApi?: boolean;
readonly metadata?: Readonly<Record<string, string>>;
readonly supportEfforts?: readonly string[];
readonly vertexai?: boolean;
readonly project?: string;
readonly location?: string;
}
/**
* Configuration passed to the protocol adapter to produce a request handler.
* Keep this shape wire-agnostic: identity comes from `protocol` + `baseUrl`,
* secrets come from `auth` (resolved by the caller from Platform / Model
* overrides), constructor-level headers come from `defaultHeaders`.
* overrides), constructor-level headers come from `defaultHeaders`, and
* provider-specific knobs are isolated under `providerOptions`.
*/
export interface ProtocolAdapterConfig {
readonly protocol: Protocol;
@ -42,8 +55,7 @@ export interface ProtocolAdapterConfig {
readonly modelName: string;
readonly apiKey?: string;
readonly defaultHeaders?: Readonly<Record<string, string>>;
/** Escape hatch for per-protocol tuning that doesn't fit the common shape. */
readonly extras?: Readonly<Record<string, unknown>>;
readonly providerOptions?: ProtocolProviderOptions;
}
export interface IProtocolAdapterRegistry {

View file

@ -59,11 +59,19 @@ function toKosongProviderConfig(input: ProtocolAdapterConfig): KosongProviderCon
baseUrl: input.baseUrl,
apiKey: input.apiKey,
defaultHeaders: input.defaultHeaders as Record<string, string> | undefined,
...(input.extras ?? {}),
...definedOptions(input.providerOptions ?? {}),
};
return base as KosongProviderConfig;
}
function definedOptions(options: object): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(options)) {
if (value !== undefined) out[key] = value;
}
return out;
}
registerScopedService(
LifecycleScope.App,
IProtocolAdapterRegistry,

View file

@ -120,6 +120,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
this.sessions.set(opts.sessionId, handle);
await handle.accessor.get(ISessionMetadata).ready;
void handle.accessor.get(ISessionSkillCatalog).ready;
await handle.accessor.get(IAgentLifecycleService).ensureMcpReady();
this._onDidCreateSession.fire({ sessionId: opts.sessionId, handle });
return handle;
}

View file

@ -100,6 +100,12 @@ export interface IAgentLifecycleService {
readonly onDidDispose: Event<string>;
/** Create an agent from zero (empty context). */
create(opts?: CreateAgentOptions): Promise<IAgentScopeHandle>;
/**
* Resolve the session/plugin MCP config and wait for the initial connection
* attempt to finish. Per-server failures are reflected in MCP status entries
* rather than rejecting this promise.
*/
ensureMcpReady(): Promise<void>;
/**
* Fire {@link onDidCreateMain} for the given handle. Called exactly once by
* the main-agent bootstrapper (`ensureMainAgent`) after main-only wirings

View file

@ -71,6 +71,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
private readonly onDidCreateMainEmitter = this._register(new Emitter<IAgentScopeHandle>());
private readonly onDidDisposeEmitter = this._register(new Emitter<string>());
private mcpManager: McpConnectionManager | undefined;
private mcpInitialLoad: Promise<void> | undefined;
get onDidCreate() {
return this.onDidCreateEmitter.event;
@ -99,6 +100,8 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
async create(opts: CreateAgentOptions = {}): Promise<IAgentScopeHandle> {
const agentId = opts.agentId ?? `agent-${nextAgentId++}`;
const mcpManager = this.getMcpManager();
const mcpReady = this.ensureMcpReady();
// Per-agent homedir → the wire-record persistence key (`hashKey(homedir)`).
// Bootstrap computes it under the session dir, mirroring v1's
// `<sessionDir>/agents/<id>`; business code never assembles the path itself.
@ -134,7 +137,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
IAgentMcpService,
new SyncDescriptor(AgentMcpService, [
{
manager: this.getMcpManager(),
manager: mcpManager,
originalsDir: sessionMediaOriginalsDir(this.ctx.sessionDir),
},
]),
@ -174,12 +177,23 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
// first turn — otherwise plugin/session MCP servers would connect but their
// tools would never register until something explicitly requests the service.
handle.accessor.get(IAgentMcpService);
await mcpReady;
if (opts.binding !== undefined) {
await handle.accessor.get(IAgentProfileService).bind(opts.binding);
}
return handle;
}
ensureMcpReady(): Promise<void> {
if (this.mcpInitialLoad !== undefined) return this.mcpInitialLoad;
const manager = this.getMcpManager();
const initialLoad = this.connectMcpServers(manager).catch((error: unknown) => {
this.log.error('mcp initial load failed', { error });
});
this.mcpInitialLoad = initialLoad;
return initialLoad;
}
notifyMainCreated(handle: IAgentScopeHandle): void {
this.onDidCreateMainEmitter.fire(handle);
}
@ -235,9 +249,9 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
/**
* One shared `McpConnectionManager` per session (built lazily, cached). All
* agents in the session share it, matching v1's session-scoped MCP and
* avoiding a reconnect storm per agent. Connects the session-config
* servers merged with enabled plugin MCP servers (fire-and-forget; the
* manager's `initialLoad` gates tool use via `waitForInitialLoad`).
* avoiding a reconnect storm per agent. The initial connect is driven
* through `ensureMcpReady`, so session creation and first agent creation can
* await config resolution before tool execution starts.
*/
private getMcpManager(): McpConnectionManager {
if (this.mcpManager !== undefined) return this.mcpManager;
@ -251,9 +265,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
});
this.mcpManager = manager;
this._register({ dispose: () => void manager.shutdown() });
void this.connectMcpServers(manager).catch((error: unknown) => {
this.log.error('mcp initial load failed', { error });
});
return manager;
}

View file

@ -78,6 +78,7 @@ describe('applyCompletionBudget', () => {
generate: vi.fn(),
withThinking: vi.fn(),
withMaxCompletionTokens: withMaxCompletionTokens as unknown as (n: number) => Model,
withProviderOptions: vi.fn(),
} as unknown as Model;
});

View file

@ -4,7 +4,8 @@ import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { Event } from '#/_base/event';
import { IAgentMcpService } from '#/agent/mcp';
import { IAgentMcpService, type McpServerConfig } from '#/agent/mcp';
import { McpConnectionManager } from '#/agent/mcp/connection-manager';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { AgentLifecycleService } from '#/session/agentLifecycle/agentLifecycleService';
import { IBootstrapService } from '#/app/bootstrap';
@ -50,6 +51,10 @@ const pluginServiceStub = {
enabledHooks: async () => [],
} as unknown as IPluginService;
function tick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
describe('AgentLifecycleService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
@ -199,6 +204,48 @@ describe('AgentLifecycleService', () => {
]);
});
it('waits for MCP config resolution and initial connect before returning an agent', async () => {
let resolvePluginServers:
| ((servers: Record<string, McpServerConfig>) => void)
| undefined;
const pluginServers = new Promise<Record<string, McpServerConfig>>((resolve) => {
resolvePluginServers = resolve;
});
ix.stub(IPluginService, {
...pluginServiceStub,
enabledMcpServers: () => pluginServers,
} as unknown as IPluginService);
let resolveConnect: (() => void) | undefined;
const connected = new Promise<void>((resolve) => {
resolveConnect = resolve;
});
const connectAll = vi
.spyOn(McpConnectionManager.prototype, 'connectAll')
.mockReturnValue(connected);
const svc = ix.get(IAgentLifecycleService);
let settled = false;
const create = svc.create({ agentId: 'main' }).then(() => {
settled = true;
});
await tick();
expect(settled).toBe(false);
expect(connectAll).not.toHaveBeenCalled();
resolvePluginServers?.({
delayed: { transport: 'stdio', command: process.execPath },
});
await tick();
expect(connectAll).toHaveBeenCalledTimes(1);
expect(settled).toBe(false);
resolveConnect?.();
await create;
expect(settled).toBe(true);
});
it('fork throws when the source agent does not exist', async () => {
const svc = ix.get(IAgentLifecycleService);
await expect(svc.fork('missing')).rejects.toThrow('Source agent "missing" does not exist');

View file

@ -80,6 +80,7 @@ describe('RestGateway', () => {
onDidCreateMain: () => ({ dispose: () => {} }),
notifyMainCreated: () => {},
create: () => Promise.resolve(agentHandle),
ensureMcpReady: () => Promise.resolve(),
fork: () => Promise.resolve(agentHandle),
run: () => {
throw new Error('not implemented in test');

View file

@ -184,6 +184,8 @@ interface ModelConfigForConfig {
readonly maxContextSize: number;
readonly maxOutputSize?: number;
readonly capabilities?: readonly string[];
readonly supportEfforts?: readonly string[];
readonly defaultEffort?: string;
}
interface ProviderConfigForConfig {
@ -2250,7 +2252,7 @@ function createGenerateBackedProtocolRegistry(generate: GenerateFn): IProtocolAd
baseUrl: input.baseUrl,
apiKey: input.apiKey,
defaultHeaders: input.defaultHeaders as Record<string, string> | undefined,
...(input.extras ?? {}),
...(input.providerOptions ?? {}),
} as ProviderConfig;
return input.protocol === 'kimi'
? new GenerateBackedKimiChatProvider(

View file

@ -17,8 +17,8 @@ import {
MODELS_SECTION,
ModelsSectionSchema,
} from '#/app/model/model';
import { modelsFromToml, modelsToToml } from '#/app/model/configSection';
import { ModelService } from '#/app/model/modelService';
import '#/app/model/configSection';
import { ENV_MODEL_PROVIDER_KEY } from '#/app/provider/provider';
describe('ModelService', () => {
@ -97,6 +97,65 @@ describe('ModelService', () => {
});
});
describe('models TOML transforms', () => {
it('camelCases nested model overrides from TOML', () => {
expect(
modelsFromToml({
kimi: {
provider: 'p',
model: 'm',
max_context_size: 1000,
support_efforts: ['low', 'high', 'max'],
overrides: {
max_context_size: 500,
support_efforts: ['low', 'high'],
},
},
}),
).toEqual({
kimi: {
provider: 'p',
model: 'm',
maxContextSize: 1000,
supportEfforts: ['low', 'high', 'max'],
overrides: {
maxContextSize: 500,
supportEfforts: ['low', 'high'],
},
},
});
});
it('snakeCases nested model overrides for TOML', () => {
expect(
modelsToToml(
{
kimi: {
provider: 'p',
model: 'm',
maxContextSize: 1000,
overrides: {
maxContextSize: 500,
supportEfforts: ['low', 'high'],
},
},
},
{},
),
).toEqual({
kimi: {
provider: 'p',
model: 'm',
max_context_size: 1000,
overrides: {
max_context_size: 500,
support_efforts: ['low', 'high'],
},
},
});
});
});
type EnvMap = Readonly<Record<string, string | undefined>>;
function applyKimiModelEnvOverlay(

View file

@ -19,9 +19,10 @@ import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { IOAuthService } from '#/app/auth';
import { IConfigService } from '#/app/config/config';
import { APIStatusError } from '#/app/llmProtocol';
import { type ModelConfig, IModelResolver, IModelService } from '#/app/model';
import { ModelResolverService } from '#/app/model/modelResolverService';
import { IPlatformService } from '#/app/platform';
import { type PlatformConfig, IPlatformService } from '#/app/platform';
import { type ProviderConfig, IProviderService } from '#/app/provider';
import {
type ChatProvider,
@ -29,10 +30,14 @@ import {
type ProtocolAdapterConfig,
} from '#/app/protocol';
let generateImpl: ChatProvider['generate'];
let uploadVideoImpl: NonNullable<ChatProvider['uploadVideo']> | undefined;
describe('ModelResolverService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let providers: Record<string, ProviderConfig>;
let platforms: Record<string, PlatformConfig>;
let models: Record<string, ModelConfig>;
let configValues: Record<string, unknown>;
let resolveTokenProvider: ReturnType<typeof vi.fn>;
@ -41,10 +46,21 @@ describe('ModelResolverService', () => {
beforeEach(() => {
disposables = new DisposableStore();
providers = {};
platforms = {};
models = {};
configValues = {};
resolveTokenProvider = vi.fn();
createdProtocolConfigs = [];
generateImpl = async () => ({
id: null,
usage: null,
finishReason: 'completed',
rawFinishReason: null,
async *[Symbol.asyncIterator]() {
yield { type: 'text' as const, text: 'ok' };
},
});
uploadVideoImpl = undefined;
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.definePartialInstance(IConfigService, {
@ -55,8 +71,8 @@ describe('ModelResolverService', () => {
list: (() => providers) as IProviderService['list'],
});
reg.definePartialInstance(IPlatformService, {
get: (() => undefined) as IPlatformService['get'],
list: (() => ({})) as IPlatformService['list'],
get: ((name: string) => platforms[name]) as IPlatformService['get'],
list: (() => platforms) as IPlatformService['list'],
});
reg.definePartialInstance(IModelService, {
get: ((id: string) => models[id]) as IModelService['get'],
@ -81,6 +97,15 @@ describe('ModelResolverService', () => {
afterEach(() => disposables.dispose());
async function resolveAndCreateProvider(modelId = 'm'): Promise<Record<string, unknown>> {
const model = ix.get(IModelResolver).resolve(modelId);
for await (const _event of model.request({ systemPrompt: '', tools: [], messages: [] })) {
void _event;
}
expect(createdProtocolConfigs).toHaveLength(1);
return createdProtocolConfigs[0]!;
}
it('returns the provider apiKey as ProviderRequestAuth.apiKey', async () => {
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk-test' };
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
@ -119,6 +144,34 @@ describe('ModelResolverService', () => {
expect(resolveTokenProvider).toHaveBeenCalledWith('p', { storage: 'file', key: 'oauth/test' });
});
it('throws login_required when an OAuth provider has no token provider', async () => {
providers['p'] = {
type: 'kimi',
baseUrl: 'https://example.test/v1',
oauth: { storage: 'file', key: 'oauth/test' },
};
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
await expect(ix.get(IModelResolver).resolve('m').authProvider.getAuth()).rejects.toMatchObject({
code: 'auth.login_required',
});
});
it('throws login_required when an OAuth token provider returns an empty token', async () => {
providers['p'] = {
type: 'kimi',
baseUrl: 'https://example.test/v1',
oauth: { storage: 'file', key: 'oauth/test' },
};
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
resolveTokenProvider.mockReturnValue({ getAccessToken: async () => ' ' });
await expect(ix.get(IModelResolver).resolve('m').authProvider.getAuth()).rejects.toMatchObject({
code: 'auth.login_required',
});
});
it('returns undefined when the model carries no auth material', async () => {
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1' };
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
@ -143,6 +196,220 @@ describe('ModelResolverService', () => {
expect(auth).toEqual({ apiKey: 'oauth-token' });
});
it.each([
['kimi', 'KIMI_API_KEY'],
['openai', 'OPENAI_API_KEY'],
['openai_responses', 'OPENAI_API_KEY'],
['anthropic', 'ANTHROPIC_API_KEY'],
['google-genai', 'GOOGLE_API_KEY'],
['vertexai', 'VERTEXAI_API_KEY'],
] as const)('uses %s provider env API key fallback', async (type, key) => {
providers['p'] = {
type,
baseUrl: 'https://example.test/v1',
env: { [key]: `${type}-token` },
};
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
const auth = await ix.get(IModelResolver).resolve('m').authProvider.getAuth();
expect(auth).toEqual({ apiKey: `${type}-token` });
});
it('uses GOOGLE_API_KEY as the Vertex API key fallback when VERTEXAI_API_KEY is absent', async () => {
providers['p'] = {
type: 'vertexai',
baseUrl: 'https://example.test/v1',
env: { GOOGLE_API_KEY: 'google-token' },
};
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
const auth = await ix.get(IModelResolver).resolve('m').authProvider.getAuth();
expect(auth).toEqual({ apiKey: 'google-token' });
});
it('uses platform auth env API key fallback before legacy provider auth', async () => {
platforms['shared'] = { auth: { env: { OPENAI_API_KEY: 'platform-token' } } };
providers['p'] = {
type: 'openai',
baseUrl: 'https://example.test/v1',
platformId: 'shared',
env: { OPENAI_API_KEY: 'provider-token' },
};
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
const auth = await ix.get(IModelResolver).resolve('m').authProvider.getAuth();
expect(auth).toEqual({ apiKey: 'platform-token' });
});
it('rejects provider oauth when an env API key also resolves', () => {
providers['p'] = {
type: 'kimi',
baseUrl: 'https://example.test/v1',
oauth: { storage: 'file', key: 'oauth/test' },
env: { KIMI_API_KEY: 'env-token' },
};
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
expect(() => ix.get(IModelResolver).resolve('m')).toThrow(
'Provider "p" has both apiKey and oauth set in config.toml',
);
});
it('rejects platform oauth when an env API key also resolves', () => {
platforms['shared'] = {
auth: {
oauth: { storage: 'file', key: 'oauth/platform' },
env: { OPENAI_API_KEY: 'platform-token' },
},
};
providers['p'] = {
type: 'openai',
baseUrl: 'https://example.test/v1',
platformId: 'shared',
};
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
expect(() => ix.get(IModelResolver).resolve('m')).toThrow(
'Platform "shared" has both apiKey and oauth set in config.toml',
);
});
describe('OAuth refresh replay', () => {
function configureOAuthModel(): void {
providers['p'] = {
type: 'kimi',
baseUrl: 'https://example.test/v1',
oauth: { storage: 'file', key: 'oauth/test' },
};
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
}
it('force-refreshes OAuth credentials and replays a request after 401', async () => {
configureOAuthModel();
const tokenCalls: boolean[] = [];
const authKeys: string[] = [];
resolveTokenProvider.mockReturnValue({
getAccessToken: async (options: { readonly force: boolean }) => {
tokenCalls.push(options.force);
return options.force ? 'forced-refresh-token' : 'fresh-token';
},
});
generateImpl = async (_system, _tools, _history, options) => {
authKeys.push(options?.auth?.apiKey ?? '<missing>');
if (authKeys.length === 1) {
throw new APIStatusError(401, 'Unauthorized', 'req-401');
}
return {
id: null,
usage: null,
finishReason: 'completed',
rawFinishReason: null,
async *[Symbol.asyncIterator]() {
yield { type: 'text' as const, text: 'recovered' };
},
};
};
const events = [];
for await (const event of ix.get(IModelResolver).resolve('m').request({
systemPrompt: '',
tools: [],
messages: [],
})) {
events.push(event);
}
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
expect(tokenCalls).toEqual([false, true]);
expect(events).toContainEqual({ type: 'part', part: { type: 'text', text: 'recovered' } });
});
it('throws login_required when force-refresh and replay both 401', async () => {
configureOAuthModel();
const authKeys: string[] = [];
resolveTokenProvider.mockReturnValue({
getAccessToken: async (options: { readonly force: boolean }) =>
options.force ? 'forced-refresh-token' : 'fresh-token',
});
generateImpl = async (_system, _tools, _history, options) => {
authKeys.push(options?.auth?.apiKey ?? '<missing>');
throw new APIStatusError(401, 'Unauthorized', 'req-401');
};
const events = ix.get(IModelResolver).resolve('m').request({
systemPrompt: '',
tools: [],
messages: [],
});
await expect(async () => {
for await (const _event of events) {
void _event;
}
}).rejects.toMatchObject({
code: 'auth.login_required',
details: {
statusCode: 401,
requestId: 'req-401',
},
});
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
});
it('keeps non-OAuth 401 as the provider status error', async () => {
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk-test' };
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
generateImpl = async () => {
throw new APIStatusError(401, 'Unauthorized', 'req-api-key-401');
};
const events = ix.get(IModelResolver).resolve('m').request({
systemPrompt: '',
tools: [],
messages: [],
});
await expect(async () => {
for await (const _event of events) {
void _event;
}
}).rejects.toMatchObject({
name: 'APIStatusError',
statusCode: 401,
requestId: 'req-api-key-401',
});
});
it('force-refreshes OAuth credentials and replays video upload after 401', async () => {
configureOAuthModel();
const tokenCalls: boolean[] = [];
const authKeys: string[] = [];
resolveTokenProvider.mockReturnValue({
getAccessToken: async (options: { readonly force: boolean }) => {
tokenCalls.push(options.force);
return options.force ? 'forced-refresh-token' : 'fresh-token';
},
});
uploadVideoImpl = async (_input, options) => {
authKeys.push(options?.auth?.apiKey ?? '<missing>');
if (authKeys.length === 1) {
throw new APIStatusError(401, 'Unauthorized', 'req-upload-401');
}
return { type: 'video_url', videoUrl: { url: 'https://example.test/video' } };
};
const result = await ix.get(IModelResolver).resolve('m').uploadVideo?.('clip.mp4');
expect(result).toEqual({
type: 'video_url',
videoUrl: { url: 'https://example.test/video' },
});
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
expect(tokenCalls).toEqual([false, true]);
});
});
describe('provider headers', () => {
it('passes provider customHeaders to protocol adapters as defaultHeaders', async () => {
providers['p'] = {
@ -167,6 +434,157 @@ describe('ModelResolverService', () => {
});
});
describe('provider options', () => {
it('passes an OpenAI reasoningKey through to the protocol adapter', async () => {
providers['p'] = { type: 'openai', baseUrl: 'https://example.test/v1', apiKey: 'sk' };
models['m'] = {
provider: 'p',
model: 'deepseek-v4-flash',
maxContextSize: 1000,
reasoningKey: ' reasoning_content ',
};
const config = await resolveAndCreateProvider();
expect(config).toMatchObject({
protocol: 'openai',
providerOptions: { reasoningKey: 'reasoning_content' },
});
});
it('passes Anthropic max-output and thinking knobs through to the protocol adapter', async () => {
providers['p'] = { type: 'anthropic', baseUrl: 'https://example.test/v1', apiKey: 'sk' };
models['m'] = {
provider: 'p',
model: 'claude-opus-4-7',
maxContextSize: 200000,
maxOutputSize: 24000,
adaptiveThinking: false,
betaApi: true,
};
const config = await resolveAndCreateProvider();
expect(config).toMatchObject({
protocol: 'anthropic',
providerOptions: {
defaultMaxTokens: 24000,
adaptiveThinking: false,
betaApi: true,
},
});
});
it('passes Anthropic metadata through to the protocol adapter', async () => {
providers['p'] = { type: 'anthropic', baseUrl: 'https://example.test/v1', apiKey: 'sk' };
models['m'] = {
provider: 'p',
model: 'claude-sonnet-4-5',
maxContextSize: 200000,
};
const model = ix.get(IModelResolver).resolve('m').withProviderOptions({
metadata: { user_id: 'session-test' },
});
for await (const _event of model.request({ systemPrompt: '', tools: [], messages: [] })) {
void _event;
}
expect(createdProtocolConfigs).toHaveLength(1);
expect(createdProtocolConfigs[0]).toMatchObject({
protocol: 'anthropic',
providerOptions: { metadata: { user_id: 'session-test' } },
});
});
it('passes Kimi supportEfforts through to the protocol adapter', async () => {
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' };
models['m'] = {
provider: 'p',
model: 'kimi-for-coding',
maxContextSize: 1000,
supportEfforts: ['low', 'high', 'max'],
};
const config = await resolveAndCreateProvider();
expect(config).toMatchObject({
protocol: 'kimi',
providerOptions: { supportEfforts: ['low', 'high', 'max'] },
});
});
it('passes overridden Kimi supportEfforts through to the protocol adapter', async () => {
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' };
models['m'] = {
provider: 'p',
model: 'kimi-for-coding',
maxContextSize: 1000,
supportEfforts: ['low', 'high', 'max'],
overrides: { supportEfforts: ['low', 'high'] },
};
const config = await resolveAndCreateProvider();
expect(config).toMatchObject({
protocol: 'kimi',
providerOptions: { supportEfforts: ['low', 'high'] },
});
});
it('passes Vertex service-account options and derives location from the baseUrl', async () => {
providers['p'] = {
type: 'vertexai',
baseUrl: 'https://us-central1-aiplatform.googleapis.com',
env: { GOOGLE_CLOUD_PROJECT: 'my-project' },
};
models['m'] = {
provider: 'p',
model: 'gemini-1.5-pro',
maxContextSize: 1000000,
};
const config = await resolveAndCreateProvider();
expect(config).toMatchObject({
protocol: 'vertexai',
baseUrl: 'https://us-central1-aiplatform.googleapis.com',
providerOptions: {
vertexai: true,
project: 'my-project',
location: 'us-central1',
},
});
});
it('uses GOOGLE_VERTEX_BASE_URL as the structured Vertex provider baseUrl fallback', async () => {
providers['p'] = {
type: 'vertexai',
env: {
GOOGLE_CLOUD_PROJECT: 'my-project',
GOOGLE_VERTEX_BASE_URL: 'https://europe-west4-aiplatform.googleapis.com',
},
};
models['m'] = {
provider: 'p',
model: 'gemini-1.5-pro',
maxContextSize: 1000000,
};
const config = await resolveAndCreateProvider();
expect(config).toMatchObject({
protocol: 'vertexai',
baseUrl: 'https://europe-west4-aiplatform.googleapis.com',
providerOptions: {
vertexai: true,
project: 'my-project',
location: 'europe-west4',
},
});
});
});
describe('capabilities', () => {
it('merges every declared capability with the model context window', () => {
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' };
@ -214,8 +632,39 @@ describe('ModelResolverService', () => {
return ix.get(IModelResolver).resolve('m').thinkingEffort;
}
it('defaults to "high" when thinking is not disabled', () => {
expect(resolveEffort()).toBe('high');
it('defaults to off when the model does not declare thinking support', () => {
expect(resolveEffort()).toBeNull();
});
it('defaults to boolean on when the model supports thinking without named efforts', () => {
expect(resolveEffort(['thinking'])).toBe('on');
});
it('uses the model default effort when configured', () => {
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' };
models['m'] = {
provider: 'p',
model: 'wire-name',
maxContextSize: 1000,
capabilities: ['thinking'],
supportEfforts: ['low', 'medium', 'max'],
defaultEffort: 'max',
};
expect(ix.get(IModelResolver).resolve('m').thinkingEffort).toBe('max');
});
it('uses the middle supported effort when no model default effort is configured', () => {
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' };
models['m'] = {
provider: 'p',
model: 'wire-name',
maxContextSize: 1000,
capabilities: ['thinking'],
supportEfforts: ['low', 'medium', 'max'],
};
expect(ix.get(IModelResolver).resolve('m').thinkingEffort).toBe('medium');
});
it('is off (null) when defaultThinking is false', () => {
@ -235,11 +684,44 @@ describe('ModelResolverService', () => {
it('clamps an explicit off back to on for always_thinking models', () => {
configValues['defaultThinking'] = false;
expect(resolveEffort(['always_thinking'])).toBe('high');
expect(resolveEffort(['always_thinking'])).toBe('on');
});
});
describe('baseUrl normalization', () => {
it.each([
['kimi', 'KIMI_BASE_URL'],
['openai', 'OPENAI_BASE_URL'],
['openai_responses', 'OPENAI_BASE_URL'],
['anthropic', 'ANTHROPIC_BASE_URL'],
['google-genai', 'GOOGLE_GEMINI_BASE_URL'],
] as const)('uses %s provider env baseUrl fallback', (type, key) => {
providers['p'] = {
type,
apiKey: 'sk',
env: { [key]: `https://${type}.example.test/v1` },
};
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
expect(ix.get(IModelResolver).resolve('m').baseUrl).toBe(
`https://${type}.example.test/v1`,
);
});
it('falls through an empty provider baseUrl to the provider env fallback', () => {
providers['p'] = {
type: 'kimi',
baseUrl: ' ',
apiKey: 'sk',
env: { KIMI_BASE_URL: 'https://kimi-env.example.test/v1' },
};
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
expect(ix.get(IModelResolver).resolve('m').baseUrl).toBe(
'https://kimi-env.example.test/v1',
);
});
function resolveBaseUrl(protocol: string, providerType: string, baseUrl: string): string {
providers['p'] = { type: providerType, baseUrl, apiKey: 'sk' } as ProviderConfig;
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000, protocol } as ModelConfig;
@ -258,6 +740,19 @@ describe('ModelResolverService', () => {
);
});
it('does not strip /v1 from a native Anthropic provider when model.protocol is unset', () => {
providers['p'] = {
type: 'anthropic',
baseUrl: 'https://api.anthropic.example/v1',
apiKey: 'sk',
};
models['m'] = { provider: 'p', model: 'claude-sonnet', maxContextSize: 200000 };
expect(ix.get(IModelResolver).resolve('m').baseUrl).toBe(
'https://api.anthropic.example/v1',
);
});
it('does not strip /v1 for non-anthropic protocols', () => {
expect(resolveBaseUrl('kimi', 'kimi', 'https://example.test/coding/v1')).toBe(
'https://example.test/coding/v1',
@ -270,16 +765,12 @@ const fakeChatProvider: ChatProvider = {
name: 'fake',
modelName: 'wire-name',
thinkingEffort: null,
async generate() {
return {
id: null,
usage: null,
finishReason: 'completed',
rawFinishReason: null,
async *[Symbol.asyncIterator]() {
yield { type: 'text' as const, text: 'ok' };
},
};
generate(systemPrompt, tools, history, options) {
return generateImpl(systemPrompt, tools, history, options);
},
uploadVideo(input, options) {
if (uploadVideoImpl === undefined) throw new Error('uploadVideo not configured');
return uploadVideoImpl(input, options);
},
withThinking() {
return this;

View file

@ -211,6 +211,7 @@ describe('ConfigState thinking clamp for always-thinking models', () => {
model: 'kimi-deep-coder',
maxContextSize: 128_000,
capabilities: ['thinking', 'always_thinking', 'tool_use'],
supportEfforts: ['low', 'high', 'max'],
},
'kimi-code/toggle': {
provider: 'kimi',
@ -218,6 +219,14 @@ describe('ConfigState thinking clamp for always-thinking models', () => {
maxContextSize: 128_000,
capabilities: ['thinking'],
},
'kimi-code/custom': {
provider: 'kimi',
model: 'kimi-custom-coder',
maxContextSize: 128_000,
capabilities: ['thinking'],
supportEfforts: ['low', 'medium', 'max'],
defaultEffort: 'max',
},
},
};
ctx = createTestAgent(configServices(() => kimiConfig));
@ -254,6 +263,12 @@ describe('ConfigState thinking clamp for always-thinking models', () => {
expect(profile.data().thinkingLevel).toBe('off');
});
it('maps thinking on to the model default effort', () => {
profile.update({ modelAlias: 'kimi-code/custom', thinkingLevel: 'on' });
expect(profile.data().thinkingLevel).toBe('max');
});
it('re-clamps when switching to an always-on model after thinking was off', () => {
profile.update({ modelAlias: 'kimi-code/toggle', thinkingLevel: 'off' });
expect(profile.data().thinkingLevel).toBe('off');

View file

@ -8,7 +8,8 @@ import { ProfileModel } from '#/agent/profile/profileOps';
import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog';
import { IBootstrapService } from '#/app/bootstrap';
import { IConfigService } from '#/app/config';
import { IModelResolver } from '#/app/model';
import type { GenerationKwargs, ThinkingEffort } from '#/app/llmProtocol';
import { IModelResolver, type Model } from '#/app/model';
import { ITelemetryService } from '#/app/telemetry';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
@ -16,6 +17,7 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { ISessionContext } from '#/session/sessionContext';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { IAgentWireService, WireService, type IWireService, type PersistedRecord } from '#/wire';
@ -33,7 +35,7 @@ function createTelemetryStub(): ITelemetryService {
function createConfigStub(): IConfigService {
return {
_serviceBrand: undefined,
get: () => undefined,
get: ((key: string) => configValues[key]) as unknown as IConfigService['get'],
} as unknown as IConfigService;
}
@ -50,11 +52,28 @@ function stubUnused<T>(): T {
return { _serviceBrand: undefined } as unknown as T;
}
function createSessionContextStub(): ISessionContext {
return {
_serviceBrand: undefined,
sessionId: 'session-test',
workspaceId: 'workspace-test',
sessionDir: '/tmp/session-test',
metaScope: 'sessions/workspace-test/session-test',
cwd: '/tmp',
scope: (subKey?: string) =>
subKey === undefined || subKey.length === 0
? 'sessions/workspace-test/session-test'
: `sessions/workspace-test/session-test/${subKey}`,
};
}
let disposables: DisposableStore;
let ix: TestInstantiationService;
let log: IAppendLogStore;
let wire: IWireService;
let svc: IAgentProfileService;
let configValues: Record<string, unknown>;
let modelResolver: IModelResolver;
function buildHost(key: string): {
ix: TestInstantiationService;
@ -68,10 +87,11 @@ function buildHost(key: string): {
host.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }]));
host.stub(ITelemetryService, createTelemetryStub());
host.stub(IConfigService, createConfigStub());
host.stub(IModelResolver, createModelResolverStub());
host.stub(IModelResolver, modelResolver);
host.stub(IHostEnvironment, stubUnused());
host.stub(IHostFileSystem, stubUnused());
host.stub(IBootstrapService, stubUnused());
host.stub(ISessionContext, createSessionContextStub());
host.stub(ISessionWorkspaceContext, stubUnused());
host.stub(IAgentProfileCatalogService, stubUnused());
host.stub(ISessionSkillCatalog, stubUnused());
@ -86,6 +106,8 @@ function buildHost(key: string): {
beforeEach(() => {
disposables = new DisposableStore();
configValues = {};
modelResolver = createModelResolverStub();
const host = buildHost(KEY);
ix = host.ix;
wire = host.wire;
@ -107,6 +129,52 @@ function modelOf(target: IWireService) {
return target.getModel(ProfileModel);
}
function createRecordingModel(
generationKwargs: GenerationKwargs[],
thinkingEfforts: ThinkingEffort[],
providerOptions: unknown[] = [],
protocol: Model['protocol'] = 'kimi',
): Model {
const build = (thinkingEffort: ThinkingEffort | null): Model => ({
id: 'kimi-code',
name: 'kimi-for-coding',
aliases: [],
protocol,
baseUrl: 'https://example.test/v1',
headers: {},
capabilities: {
image_in: false,
video_in: false,
audio_in: false,
thinking: true,
tool_use: false,
max_context_tokens: 1000,
},
maxContextSize: 1000,
thinkingEffort,
alwaysThinking: false,
providerName: 'kimi',
authProvider: { getAuth: async () => undefined },
withThinking: (effort) => {
thinkingEfforts.push(effort);
return build(effort);
},
withMaxCompletionTokens: () => build(thinkingEffort),
withGenerationKwargs: (kwargs) => {
generationKwargs.push(kwargs);
return build(thinkingEffort);
},
withProviderOptions: (options) => {
providerOptions.push(options);
return build(thinkingEffort);
},
request: async function* () {
return;
},
});
return build(null);
}
describe('AgentProfileService (wire-backed config.update)', () => {
it('update persists a flat config.update record and resolves thinkingLevel at the call site', async () => {
svc.update({ profileName: DEFAULT_AGENT_PROFILE_NAME, systemPrompt: 'You are helpful.' });
@ -115,8 +183,9 @@ describe('AgentProfileService (wire-backed config.update)', () => {
const model = modelOf(wire);
expect(model.profileName).toBe(DEFAULT_AGENT_PROFILE_NAME);
expect(model.systemPrompt).toBe('You are helpful.');
// 'on' resolves against the default effort (no thinking config section) → 'high'.
expect(model.thinkingLevel).toBe('high');
// Explicit 'on' persists as the boolean-thinking signal when no model
// declares named efforts.
expect(model.thinkingLevel).toBe('on');
expect(svc.getSystemPrompt()).toBe('You are helpful.');
const records = await readRecords();
@ -126,7 +195,7 @@ describe('AgentProfileService (wire-backed config.update)', () => {
profileName: DEFAULT_AGENT_PROFILE_NAME,
systemPrompt: 'You are helpful.',
},
{ type: 'config.update', thinkingLevel: 'high' },
{ type: 'config.update', thinkingLevel: 'on' },
]);
expect(records.every((record) => 'payload' in record === false)).toBe(true);
});
@ -188,9 +257,93 @@ describe('AgentProfileService (wire-backed config.update)', () => {
const records = await readRecords();
// Fresh host whose config section would resolve differently is irrelevant:
// the persisted resolved value ('high') is restored verbatim.
// the persisted resolved value ('on') is restored verbatim.
const host = buildHost('profile-replay-thinking');
host.wire.replay(...records);
expect(modelOf(host.wire).thinkingLevel).toBe('high');
expect(modelOf(host.wire).thinkingLevel).toBe('on');
});
it('applies thinking.keep model override when thinking is enabled', () => {
const generationKwargs: GenerationKwargs[] = [];
const thinkingEfforts: ThinkingEffort[] = [];
modelResolver = {
_serviceBrand: undefined,
resolve: () => createRecordingModel(generationKwargs, thinkingEfforts),
findByName: () => [],
};
const host = buildHost('profile-thinking-keep');
host.svc.configure({ emitStatusUpdated: () => undefined });
configValues['modelOverrides'] = { temperature: 0.3, thinkingKeep: 'all' };
host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' });
const model = host.svc.resolveModel();
expect(model?.thinkingEffort).toBe('high');
expect(thinkingEfforts).toEqual(['high']);
expect(generationKwargs).toEqual([
{
prompt_cache_key: 'session-test',
temperature: 0.3,
extra_body: { thinking: { keep: 'all' } },
},
]);
});
it('does not apply thinking.keep model override when thinking is off', () => {
const generationKwargs: GenerationKwargs[] = [];
const thinkingEfforts: ThinkingEffort[] = [];
modelResolver = {
_serviceBrand: undefined,
resolve: () => createRecordingModel(generationKwargs, thinkingEfforts),
findByName: () => [],
};
const host = buildHost('profile-thinking-keep-off');
host.svc.configure({ emitStatusUpdated: () => undefined });
configValues['modelOverrides'] = { temperature: 0.3, thinkingKeep: 'all' };
host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'off' });
host.svc.resolveModel();
expect(thinkingEfforts).toEqual(['off']);
expect(generationKwargs).toEqual([{ prompt_cache_key: 'session-test', temperature: 0.3 }]);
});
it('uses the session id as a Kimi prompt cache hint', () => {
const generationKwargs: GenerationKwargs[] = [];
const thinkingEfforts: ThinkingEffort[] = [];
modelResolver = {
_serviceBrand: undefined,
resolve: () => createRecordingModel(generationKwargs, thinkingEfforts),
findByName: () => [],
};
const host = buildHost('profile-prompt-cache-key');
host.svc.configure({ emitStatusUpdated: () => undefined });
host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' });
host.svc.resolveModel();
expect(thinkingEfforts).toEqual(['high']);
expect(generationKwargs).toEqual([{ prompt_cache_key: 'session-test' }]);
});
it('does not apply the Kimi prompt cache hint to other protocols', () => {
const generationKwargs: GenerationKwargs[] = [];
const thinkingEfforts: ThinkingEffort[] = [];
const providerOptions: unknown[] = [];
modelResolver = {
_serviceBrand: undefined,
resolve: () =>
createRecordingModel(generationKwargs, thinkingEfforts, providerOptions, 'anthropic'),
findByName: () => [],
};
const host = buildHost('profile-prompt-cache-key-anthropic');
host.svc.configure({ emitStatusUpdated: () => undefined });
host.svc.update({ modelAlias: 'claude-sonnet', thinkingLevel: 'high' });
host.svc.resolveModel();
expect(thinkingEfforts).toEqual(['high']);
expect(generationKwargs).toEqual([]);
expect(providerOptions).toEqual([{ metadata: { user_id: 'session-test' } }]);
});
});

View file

@ -11,16 +11,48 @@ describe('profile/thinking', () => {
expect(resolveThinkingEffort(undefined, { effort: 'low' })).toBe('low');
});
it('defaults to high when nothing configured', () => {
expect(resolveThinkingEffort(undefined, undefined)).toBe('high');
it('defaults to off when no model supports thinking', () => {
expect(resolveThinkingEffort(undefined, undefined)).toBe('off');
});
it('uses the model default effort when configured', () => {
expect(
resolveThinkingEffort(undefined, undefined, {
capabilities: ['thinking'],
supportEfforts: ['low', 'medium', 'max'],
defaultEffort: 'max',
}),
).toBe('max');
});
it('uses the middle supported effort when no default effort is configured', () => {
expect(
resolveThinkingEffort(undefined, undefined, {
capabilities: ['thinking'],
supportEfforts: ['low', 'medium', 'max'],
}),
).toBe('medium');
});
it('uses boolean on for thinking models without named efforts', () => {
expect(
resolveThinkingEffort(undefined, undefined, {
capabilities: ['thinking'],
}),
).toBe('on');
});
it('returns off when config mode is off and no request is provided', () => {
expect(resolveThinkingEffort(undefined, { mode: 'off' })).toBe('off');
});
it('returns high when config mode is on without explicit effort', () => {
expect(resolveThinkingEffort(undefined, { mode: 'on' })).toBe('high');
it('returns model default when config mode is on without explicit effort', () => {
expect(
resolveThinkingEffort(undefined, { mode: 'on' }, {
capabilities: ['thinking'],
supportEfforts: ['low', 'high'],
}),
).toBe('high');
});
it('returns explicit effort when both mode=on and effort are set', () => {
@ -39,30 +71,36 @@ describe('profile/thinking', () => {
expect(resolveThinkingEffort('on', { effort: 'medium' })).toBe('medium');
});
it('maps "on" to high when config has no effort', () => {
expect(resolveThinkingEffort('on', undefined)).toBe('high');
it('maps "on" to the model default when config has no effort', () => {
expect(
resolveThinkingEffort('on', undefined, {
capabilities: ['thinking'],
supportEfforts: ['low', 'medium', 'max'],
}),
).toBe('medium');
});
it('parses a named effort', () => {
expect(resolveThinkingEffort('xhigh', undefined)).toBe('xhigh');
});
it('falls back to config effort for unknown value', () => {
expect(resolveThinkingEffort('bogus', { effort: 'low' })).toBe('low');
it('carries custom requested efforts through', () => {
expect(resolveThinkingEffort('bogus', { effort: 'low' })).toBe('bogus');
});
it('falls back to default high for unknown value with no config', () => {
expect(resolveThinkingEffort('bogus', undefined)).toBe('high');
});
it('normalizes case and whitespace', () => {
it('normalizes requested effort case and whitespace', () => {
expect(resolveThinkingEffort(' Medium ', undefined)).toBe('medium');
expect(resolveThinkingEffort('OFF', { mode: 'on' })).toBe('off');
});
it('uses high as the concrete effort for the default-on state', () => {
expect(resolveThinkingEffort(undefined, undefined)).toBe('high');
expect(resolveThinkingEffort('on', undefined)).toBe('high');
it('clamps off to model default for always-thinking models', () => {
expect(
resolveThinkingEffort('off', undefined, {
capabilities: ['always_thinking'],
alwaysThinking: true,
supportEfforts: ['low', 'medium', 'max'],
}),
).toBe('medium');
});
});

View file

@ -19,4 +19,79 @@ describe('ProtocolAdapterRegistry', () => {
expect(Reflect.get(provider, '_defaultHeaders')).toEqual({ 'X-Test': '1' });
});
it('maps providerOptions into OpenAI provider config', () => {
const provider = new ProtocolAdapterRegistry().createChatProvider({
protocol: 'openai',
baseUrl: 'https://example.test/v1',
modelName: 'deepseek-v4-flash',
apiKey: 'sk',
providerOptions: { reasoningKey: 'reasoning_content' },
});
expect(Reflect.get(provider, '_reasoningKey')).toBe('reasoning_content');
});
it('maps providerOptions into Anthropic provider config', () => {
const provider = new ProtocolAdapterRegistry().createChatProvider({
protocol: 'anthropic',
baseUrl: 'https://example.test/v1',
modelName: 'unknown-model',
apiKey: 'sk',
providerOptions: {
defaultMaxTokens: 12345,
adaptiveThinking: false,
betaApi: true,
metadata: { user_id: 'session-test' },
},
});
expect(Reflect.get(provider, '_generationKwargs')).toMatchObject({ max_tokens: 12345 });
expect(Reflect.get(provider, '_adaptiveThinking')).toBe(false);
expect(Reflect.get(provider, '_betaApi')).toBe(true);
expect(Reflect.get(provider, '_metadata')).toEqual({ user_id: 'session-test' });
});
it('maps providerOptions into Kimi provider config', () => {
const provider = new ProtocolAdapterRegistry().createChatProvider({
protocol: 'kimi',
baseUrl: 'https://example.test/v1',
modelName: 'kimi-for-coding',
apiKey: 'sk',
providerOptions: { supportEfforts: ['low', 'high', 'max'] },
});
expect(Reflect.get(provider, '_supportEfforts')).toEqual(['low', 'high', 'max']);
expect(Reflect.get(provider.withThinking('high'), '_generationKwargs')).toEqual({
extra_body: { thinking: { type: 'enabled', effort: 'high' } },
});
expect(provider.withThinking('high').thinkingEffort).toBe('high');
expect(Reflect.get(provider.withThinking('medium'), '_generationKwargs')).toEqual({
extra_body: { thinking: { type: 'enabled' } },
});
expect(provider.withThinking('medium').thinkingEffort).toBe('on');
expect(
Reflect.get(provider.withThinking('high').withThinking('off'), '_generationKwargs'),
).toEqual({
extra_body: { thinking: { type: 'disabled' } },
});
expect(provider.withThinking('high').withThinking('off').thinkingEffort).toBe('off');
});
it('maps providerOptions into Vertex provider config', () => {
const provider = new ProtocolAdapterRegistry().createChatProvider({
protocol: 'vertexai',
baseUrl: 'https://us-central1-aiplatform.googleapis.com',
modelName: 'gemini-1.5-pro',
providerOptions: {
vertexai: true,
project: 'my-project',
location: 'us-central1',
},
});
expect(Reflect.get(provider, '_vertexai')).toBe(true);
expect(Reflect.get(provider, '_project')).toBe('my-project');
expect(Reflect.get(provider, '_location')).toBe('us-central1');
});
});

View file

@ -59,6 +59,7 @@ function lifecycle(handles: readonly IAgentScopeHandle[]): IAgentLifecycleServic
onDidCreateMain: () => ({ dispose: () => {} }),
notifyMainCreated: () => {},
create: () => Promise.resolve(handles[0]!),
ensureMcpReady: () => Promise.resolve(),
fork: () => Promise.resolve(handles[0]!),
run: () => {
throw new Error('not implemented in test');

View file

@ -138,6 +138,29 @@ function atomicDocumentStoreStub(): IAtomicDocumentStore {
};
}
function agentLifecycleStub(): IAgentLifecycleService {
return {
_serviceBrand: undefined,
onDidCreate: () => ({ dispose: () => {} }),
onDidCreateMain: () => ({ dispose: () => {} }),
onDidDispose: () => ({ dispose: () => {} }),
create: () => Promise.reject(new Error('not implemented')),
notifyMainCreated: () => {},
ensureMcpReady: () => Promise.resolve(),
fork: () => Promise.reject(new Error('not implemented')),
run: () => {
throw new Error('not implemented');
},
getHandle: () => undefined,
list: () => [],
remove: () => Promise.resolve(),
};
}
function tick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
describe('SessionLifecycleService', () => {
let host: ScopedTestHost | undefined;
@ -168,6 +191,7 @@ describe('SessionLifecycleService', () => {
stubPair(IAppendLogStore, appendLogStoreStub()),
stubPair(IAtomicDocumentStore, atomicDocumentStoreStub()),
stubPair(IEventService, eventStub()),
stubPair(IAgentLifecycleService, agentLifecycleStub()),
...extra,
]);
return host.app.accessor.get(ISessionLifecycleService);
@ -211,6 +235,7 @@ describe('SessionLifecycleService', () => {
},
}),
stubPair(IAgentLifecycleService, {
...agentLifecycleStub(),
_serviceBrand: undefined,
list: () => [agentHandle],
remove: (id: string) => {
@ -245,6 +270,31 @@ describe('SessionLifecycleService', () => {
expect(captured).toMatchObject({ sessionId: 's1', handle: h });
});
it('waits for MCP initialization before create returns', async () => {
let resolveMcpReady: (() => void) | undefined;
const mcpReady = new Promise<void>((resolve) => {
resolveMcpReady = resolve;
});
const svc = build([
stubPair(IAgentLifecycleService, {
...agentLifecycleStub(),
ensureMcpReady: () => mcpReady,
}),
]);
let settled = false;
const create = svc.create({ sessionId: 's1', workDir: '/tmp/proj' }).then(() => {
settled = true;
});
await tick();
expect(settled).toBe(false);
resolveMcpReady?.();
await create;
expect(settled).toBe(true);
});
it('fires onDidCloseSession when a session is closed', async () => {
const svc = build();
const closed: string[] = [];
@ -257,6 +307,7 @@ describe('SessionLifecycleService', () => {
it('fires onDidArchiveSession when a session is archived', async () => {
const svc = build([
stubPair(IAgentLifecycleService, {
...agentLifecycleStub(),
_serviceBrand: undefined,
list: () => [],
remove: () => Promise.resolve(),

View file

@ -148,6 +148,7 @@ function makeLifecycleStub(handles: readonly IAgentScopeHandle[] = []): Lifecycl
create: async () => {
throw new Error('not implemented');
},
ensureMcpReady: () => Promise.resolve(),
notifyMainCreated: () => {},
fork: async () => {
throw new Error('not implemented');