Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions apps/kimi-code/src/tui/commands/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,18 +306,35 @@ export async function setDefaultModel(
effort,
model === undefined ? undefined : effectiveModelForHost(host, model),
);
if (host.session === undefined && host.engineV2) {
// A first prompt may still be inside lazy creation: wait it out so the
// pick lands on the new session instead of racing its assembly (same
// coordination as the /model path).
await host.waitForLazyCreation();
}
await host.harness.setConfig({
defaultModel: alias,
thinking,
});
await host.authFlow.refreshConfigAfterLogin();
// Whether activation reached an already-live session (the engine tracks
// model_switch from setModel there). Recorded at activation time rather
// than snapshotted at entry: a lazy session can come live while the config
// writes above are pending, while a session created BY activation (v1)
// does not count — creation binds the model without an engine event.
let activatedLiveSession = await host.authFlow.refreshConfigAfterLogin();
// refreshConfigAfterLogin reactivates from the persisted config, so a pick
// the gate keeps session-only never reaches the runtime — apply it after
// the refresh, or the persisted value would clobber it.
if (thinking.effort === undefined && effort !== 'off' && effort !== 'on') {
await host.authFlow.activateModelAfterLogin(alias, effort);
activatedLiveSession =
(await host.authFlow.activateModelAfterLogin(alias, effort)) || activatedLiveSession;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the v1 creation-path model switch event

When a session-less v1 user selects a model with a session-only effort such as xhigh, refreshConfigAfterLogin() creates the session with that model and correctly returns false because creation emits no model_switch; the second activation then sees the newly created session and returns true, but its setModel(alias) is a no-op because that alias is already bound, so the engine emits nothing. OR-ing that result suppresses the TUI event as well, leaving this selection with zero model_switch records; preserve the creation-path result unless the second activation actually changed the model.

Useful? React with 👍 / 👎.

}
// Without a live session the engine never sees the pick (v2 defers creation
// to the first prompt, v1 binds the model on creation without an event), so
// the TUI stays the sole producer for that path.
if (!activatedLiveSession) {
host.track('model_switch', { model: alias });
}
host.track('model_switch', { model: alias });
host.showStatus(`Default model set to ${alias} with thinking ${effort}.`);
}

Expand Down
32 changes: 25 additions & 7 deletions apps/kimi-code/src/tui/controllers/auth-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,22 @@ export class AuthFlowController {
this.host.setStartupReady();
}

async activateModelAfterLogin(model: string, effort?: string): Promise<void> {
/**
* Apply a model pick to the runtime. Returns whether the pick reached an
* already-live session: the engine tracks `model_switch` from `setModel`
* there, while the session-less paths produce no engine event (v2 defers
* creation to the first prompt; v1 binds the model at creation without
* one), so callers mirroring the engine's telemetry must stay the producer
* for exactly the `false` case.
*/
async activateModelAfterLogin(model: string, effort?: string): Promise<boolean> {
const { host } = this;
if (host.session !== undefined) {
await host.session.setModel(model);
if (effort !== undefined) {
await host.session.setThinking(effort);
}
return;
return true;
}

if (host.engineV2) {
Expand All @@ -96,7 +104,7 @@ export class AuthFlowController {
patch.lazySessionThinking = effort as ThinkingEffort;
}
host.setAppState(patch);
return;
return false;
}

const options: MutableCreateSessionOptions = {
Expand Down Expand Up @@ -131,9 +139,15 @@ export class AuthFlowController {
host.updateTerminalTitle();
void host.refreshSkillCommands(host.session);
void host.refreshPluginCommands(host.session);
return false;
}

async refreshConfigAfterLogin(): Promise<void> {
/**
* Re-read config and reactivate the persisted model after login or a
* config-refreshing command. Returns whatever the activation reports (see
* {@link activateModelAfterLogin}); `false` when no activation ran.
*/
async refreshConfigAfterLogin(): Promise<boolean> {
const { host } = this;
const config = await host.harness.getConfig({ reload: true });
const availableModels = config.models ?? {};
Expand All @@ -148,16 +162,19 @@ export class AuthFlowController {
await host.hydrateLazyConfigDefaults();
}
host.setAppState({ availableModels, availableProviders });
return;
return false;
}

await this.activateModelAfterLogin(defaultModel, thinkingEffortFromConfig(config.thinking));
const activated = await this.activateModelAfterLogin(
defaultModel,
thinkingEffortFromConfig(config.thinking),
);
if (host.session === undefined && host.engineV2) {
// Session-less v2: also hydrate permission/plan defaults from the
// refreshed config, same as startup.
await host.hydrateLazyConfigDefaults();
host.setAppState({ availableModels, availableProviders });
return;
return activated;
}
const appStatePatch: Partial<AppState> = {
availableModels,
Expand All @@ -166,6 +183,7 @@ export class AuthFlowController {
maxContextTokens: selected.maxContextSize,
};
host.setAppState(appStatePatch);
return activated;
}

async refreshConfigAfterLogout(): Promise<void> {
Expand Down
52 changes: 49 additions & 3 deletions apps/kimi-code/test/tui/commands/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ import { describe, expect, it, vi } from 'vitest';
import type { SlashCommandHost } from '#/tui/commands';
import { setDefaultModel } from '#/tui/commands/provider';

function makeHost() {
function makeHost(
options: {
refreshReachedLiveSession?: boolean;
activateReachedLiveSession?: boolean;
engineV2?: boolean;
} = {},
) {
const appState = {
availableModels: {
// Declares no efforts; the Anthropic profile inference supplies
Expand All @@ -30,12 +36,14 @@ function makeHost() {
};
const host = {
state: { appState },
engineV2: options.engineV2 === true,
waitForLazyCreation: vi.fn(async () => {}),
harness: {
setConfig: vi.fn(async () => ({})),
},
authFlow: {
refreshConfigAfterLogin: vi.fn(async () => {}),
activateModelAfterLogin: vi.fn(async () => {}),
refreshConfigAfterLogin: vi.fn(async () => options.refreshReachedLiveSession === true),
activateModelAfterLogin: vi.fn(async () => options.activateReachedLiveSession === true),
},
track: vi.fn(),
showStatus: vi.fn(),
Expand All @@ -45,6 +53,8 @@ function makeHost() {
refreshConfigAfterLogin: ReturnType<typeof vi.fn>;
activateModelAfterLogin: ReturnType<typeof vi.fn>;
};
waitForLazyCreation: ReturnType<typeof vi.fn>;
track: ReturnType<typeof vi.fn>;
};
return { host };
}
Expand All @@ -65,6 +75,9 @@ describe('setDefaultModel', () => {
expect(
host.authFlow.activateModelAfterLogin.mock.invocationCallOrder[0]!,
).toBeGreaterThan(host.authFlow.refreshConfigAfterLogin.mock.invocationCallOrder[0]!);
// Without a session the engine never sees the pick, so the TUI stays the
// sole model_switch producer.
expect(host.track).toHaveBeenCalledWith('model_switch', { model: 'opus' });
});

it('does not re-apply the effort when the pick persists', async () => {
Expand All @@ -90,4 +103,37 @@ describe('setDefaultModel', () => {
});
expect(host.authFlow.activateModelAfterLogin).not.toHaveBeenCalled();
});

it('leaves model_switch to the engine when activation reached a live session', async () => {
const { host } = makeHost({ refreshReachedLiveSession: true });

await setDefaultModel(host, 'opus', 'high');

// refreshConfigAfterLogin routed through session.setModel, which the
// engine already tracks — a TUI-side event would double-count the switch.
expect(host.track).not.toHaveBeenCalled();
});

it('leaves model_switch to the engine when a lazy session came live mid-flow', async () => {
// The codex race: session-less at entry, but the first prompt's lazy
// creation completes while setConfig / the refresh are pending, so the
// session-only re-apply lands on the now-live session (engine emits).
const { host } = makeHost({ activateReachedLiveSession: true });

await setDefaultModel(host, 'opus', 'xhigh');

expect(host.authFlow.activateModelAfterLogin).toHaveBeenCalledWith('opus', 'xhigh');
expect(host.track).not.toHaveBeenCalled();
});

it('waits for an in-flight lazy creation before activating (v2)', async () => {
const { host } = makeHost({ engineV2: true });

await setDefaultModel(host, 'opus', 'high');

expect(host.waitForLazyCreation).toHaveBeenCalled();
expect(
host.waitForLazyCreation.mock.invocationCallOrder[0]!,
).toBeLessThan(host.harness.setConfig.mock.invocationCallOrder[0]!);
});
});
130 changes: 130 additions & 0 deletions apps/kimi-code/test/tui/controllers/auth-flow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, expect, it, vi } from 'vitest';

import {
AuthFlowController,
type AuthFlowHost,
} from '#/tui/controllers/auth-flow';

function makeHost(
options: {
engineV2?: boolean;
withSession?: boolean;
defaultModel?: string;
} = {},
) {
const appState = {
workDir: '/tmp/work',
additionalDirs: [] as string[],
planMode: false,
model: 'old-model',
thinkingEffort: 'off',
};
const session =
options.withSession === true
? {
id: 'ses-live',
setModel: vi.fn(async () => ({ model: 'k2', providerName: 'managed' })),
setThinking: vi.fn(async () => {}),
}
: undefined;
const host = {
state: { appState },
session,
engineV2: options.engineV2 === true,
harness: {
createSession: vi.fn(async () => ({ id: 'ses-new', summary: { title: null } })),
getConfig: vi.fn(async () => ({
defaultModel: options.defaultModel,
models: { k2: { provider: 'managed:kimi-code', model: 'kimi-k2', maxContextSize: 200_000 } },
providers: {},
})),
},
options: { startup: {} },
setAppState: vi.fn((patch: Record<string, unknown>) => Object.assign(appState, patch)),
setStartupReady: vi.fn(),
resetSessionRuntime: vi.fn(),
setSession: vi.fn(async (next: unknown) => {
(host as { session: unknown }).session = next;
}),
syncRuntimeState: vi.fn(async () => {}),
appendStartupNotice: vi.fn(),
hydrateLazyConfigDefaults: vi.fn(async () => {}),
sessionEventHandler: { startSubscription: vi.fn() },
fetchSessions: vi.fn(async () => {}),
updateTerminalTitle: vi.fn(),
refreshSkillCommands: vi.fn(async () => {}),
refreshPluginCommands: vi.fn(async () => {}),
} as unknown as AuthFlowHost & {
session: unknown;
harness: {
createSession: ReturnType<typeof vi.fn>;
getConfig: ReturnType<typeof vi.fn>;
};
setAppState: ReturnType<typeof vi.fn>;
};
return { host, appState, session };
}

describe('activateModelAfterLogin', () => {
it('applies through setModel and reports a live session when one exists', async () => {
const { host, session } = makeHost({ withSession: true });
const authFlow = new AuthFlowController(host);

const reachedLiveSession = await authFlow.activateModelAfterLogin('k2', 'high');

expect(reachedLiveSession).toBe(true);
expect(session!.setModel).toHaveBeenCalledWith('k2');
expect(session!.setThinking).toHaveBeenCalledWith('high');
});

it('only patches app state and reports no live session on the session-less v2 path', async () => {
const { host, appState } = makeHost({ engineV2: true });
const authFlow = new AuthFlowController(host);

const reachedLiveSession = await authFlow.activateModelAfterLogin('k2', 'high');

expect(reachedLiveSession).toBe(false);
expect(host.harness.createSession).not.toHaveBeenCalled();
expect(appState.model).toBe('k2');
expect(appState).toMatchObject({ lazySessionThinking: 'high' });
});

it('creates the session on the session-less v1 path and still reports no live session', async () => {
const { host } = makeHost();
const authFlow = new AuthFlowController(host);

// The v1 creation binds the model without an engine model_switch event,
// so callers must treat this path as "no live session reached" even
// though host.session is defined afterwards.
const reachedLiveSession = await authFlow.activateModelAfterLogin('k2', 'high');

expect(reachedLiveSession).toBe(false);
expect(host.harness.createSession).toHaveBeenCalledWith(
expect.objectContaining({ model: 'k2', thinking: 'high' }),
);
expect(host.session).toMatchObject({ id: 'ses-new' });
});
});

describe('refreshConfigAfterLogin', () => {
it('reports false without activating when no default model is configured', async () => {
const { host } = makeHost({ withSession: true });
const authFlow = new AuthFlowController(host);

const reachedLiveSession = await authFlow.refreshConfigAfterLogin();

expect(reachedLiveSession).toBe(false);
});

it('propagates the activation result for the persisted default model', async () => {
const live = makeHost({ withSession: true, defaultModel: 'k2' });
const reachedLive = await new AuthFlowController(live.host).refreshConfigAfterLogin();
expect(reachedLive).toBe(true);
expect(live.session!.setModel).toHaveBeenCalledWith('k2');

const lazy = makeHost({ engineV2: true, defaultModel: 'k2' });
const reachedLazy = await new AuthFlowController(lazy.host).refreshConfigAfterLogin();
expect(reachedLazy).toBe(false);
expect(lazy.host.harness.createSession).not.toHaveBeenCalled();
});
});
4 changes: 2 additions & 2 deletions packages/node-sdk/src/kimi-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,8 +650,8 @@ export class KimiHarness {
// caller-supplied sessionStartedProperties that happen to share a key.
// `client_id` is always null here: a single-process host has no
// per-connection client id (that concept only exists for daemon clients,
// see core-impl.ts). Kept as an explicit key so both producers share the
// same session_started schema.
// see core-impl.ts). Kept as an explicit key so this row carries the
// same client-attribution shape as the daemon-client producer there.
client_id: null,
client_name: this.identity?.productName ?? null,
client_version: this.identity?.version ?? null,
Expand Down
Loading
Loading