Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/logout-keeps-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Logging out or removing the active provider no longer closes the current session.
11 changes: 10 additions & 1 deletion apps/kimi-code/src/tui/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,10 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise<void>
}

if (target === currentProvider) {
// Keep the session: only the provider credential is gone. The next turn
// fails with model.not_configured until the user logs in again or picks
// another model.
Comment thread
liruifengv marked this conversation as resolved.
await host.authFlow.refreshConfigAfterLogout();
await host.authFlow.clearActiveSessionAfterLogout();
} else {
const updated = await host.harness.getConfig({ reload: true });
host.setAppState({
Expand All @@ -253,5 +255,12 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise<void>

host.track('logout', { provider: target });
const label = target === DEFAULT_OAUTH_PROVIDER_NAME ? PRODUCT_NAME : target;
if (target === currentProvider) {
host.showStatus(
`Logged out from ${label}. Current model is unavailable — /login or /model to continue.`,
Comment thread
liruifengv marked this conversation as resolved.
'warning',
);
return;
}
host.showStatus(`Logged out from ${label}.`);
}
8 changes: 8 additions & 0 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,14 @@ async function performModelSwitch(
const status = await session.getStatus();
effectiveAlias = status.model ?? alias;
effectiveEffort = status.thinkingEffort;
// A logout that retained the session zeroed the footer's context
// counters; the model switch is what heals the session here, so
// restore the counters from the live status too.
host.setAppState({
contextTokens: status.contextTokens,
maxContextTokens: status.maxContextTokens,
contextUsage: status.contextUsage,
});
}
} catch (error) {
const msg = formatErrorMessage(error);
Expand Down
31 changes: 19 additions & 12 deletions apps/kimi-code/src/tui/commands/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,29 +88,36 @@ async function handleProviderManagerDeleteSource(
}

async function handleProviderDelete(host: SlashCommandHost, providerId: string): Promise<void> {
const activeProvider =
host.state.appState.availableModels[host.state.appState.model]?.provider;

if (providerId === DEFAULT_OAUTH_PROVIDER_NAME) {
await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME);
// Drop the process-wide region cache with the credential: derived
// endpoints (updates, marketplace, site links, telemetry) must fall back
// to the marker/default profile, not the logged-out region.
refreshKimiRegion();
await host.authFlow.refreshConfigAfterLogout();
await host.authFlow.clearActiveSessionAfterLogout();
return;
} else {
await host.harness.removeProvider(providerId);
}

const activeProvider =
host.state.appState.availableModels[host.state.appState.model]?.provider;
const config = await host.harness.removeProvider(providerId);
if (activeProvider === providerId) {
// Keep the session, mirroring /logout: only the model display is cleared;
// the next turn fails with model.not_configured until the user logs in
// again or picks another model.
await host.authFlow.refreshConfigAfterLogout();
await host.authFlow.clearActiveSessionAfterLogout();
} else {
host.setAppState({
availableProviders: config.providers ?? {},
availableModels: config.models ?? {},
});
host.showStatus(
`Provider "${providerId}" was used by the current model — /login or /model to continue.`,
'warning',
);
return;
}

const updated = await host.harness.getConfig({ reload: true });
host.setAppState({
availableProviders: updated.providers ?? {},
availableModels: updated.models ?? {},
});
}

async function handleProviderAdd(host: SlashCommandHost): Promise<void> {
Expand Down
17 changes: 4 additions & 13 deletions apps/kimi-code/src/tui/controllers/auth-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export interface AuthFlowHost {
resetSessionRuntime(): void;
setSession(session: Session): Promise<void>;
syncRuntimeState(session?: Session): Promise<void>;
closeSession(reason: string): Promise<void>;
appendStartupNotice(extra: string): void;
hydrateLazyConfigDefaults(): Promise<void>;
readonly sessionEventHandler: SessionEventHandler;
Expand Down Expand Up @@ -83,6 +82,10 @@ export class AuthFlowController {
if (effort !== undefined) {
await host.session.setThinking(effort);
}
// Logging out with the session retained zeroed the footer's context
// counters; resync from the live session even when setModel was a
// no-op (same alias), so contextTokens/contextUsage are accurate again.
await host.syncRuntimeState(host.session);
return;
}

Expand Down Expand Up @@ -134,18 +137,6 @@ export class AuthFlowController {
void host.refreshPluginCommands(host.session);
}

async clearActiveSessionAfterLogout(): Promise<void> {
await this.host.closeSession('logged out');
this.host.resetSessionRuntime();
this.host.setAppState({
sessionId: '',
model: '',
sessionTitle: null,
});
await this.host.refreshSkillCommands();
await this.host.refreshPluginCommands();
}

async refreshConfigAfterLogin(): Promise<void> {
const { host } = this;
const config = await host.harness.getConfig({ reload: true });
Expand Down
117 changes: 117 additions & 0 deletions apps/kimi-code/test/tui/commands/model-switch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Scenario: /model switching on a session retained across a provider logout.
* Responsibilities: the switch must restore the context counters that logout
* zeroed (footer + cache-expiry hint read them), alongside the model itself.
* Wiring: real command and selector with the SDK/session boundary stubbed by a small host rig.
* Run: pnpm -C apps/kimi-code exec vitest run test/tui/commands/model-switch.test.ts
*/
import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk';
import { describe, expect, it, vi } from 'vitest';

import type { SlashCommandHost } from '#/tui/commands';
import { handleModelCommand } from '#/tui/commands/config';
import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector';

interface PickerOptions {
readonly models: Record<string, ModelAlias>;
readonly currentValue: string;
readonly onSelect: (selection: { alias: string; thinking: 'off' }) => void;
readonly onSessionOnlySelect: (selection: { alias: string; thinking: 'off' }) => void;
}

function model(name: string): ModelAlias {
return {
provider: 'test',
model: name,
maxContextSize: 200_000,
displayName: name,
} as unknown as ModelAlias;
}

function makeHost() {
const appState = {
availableModels: {
k2: model('k2'),
g1: model('g1'),
} as Record<string, ModelAlias>,
availableProviders: {},
// Post-logout state: the model display and the context counters were
// cleared while the session was retained.
model: '',
thinkingEffort: 'off' as const,
contextTokens: 0,
maxContextTokens: 0,
contextUsage: 0,
streamingPhase: 'idle' as const,
transcriptEntries: [],
};
const session = {
id: 'ses-1',
setModel: vi.fn(async () => {}),
setThinking: vi.fn(async () => {}),
getStatus: vi.fn(async () => ({
model: 'g1',
thinkingEffort: 'off',
permission: 'manual',
planMode: false,
contextTokens: 10,
maxContextTokens: 100,
contextUsage: 0.1,
})),
};
const host = {
state: {
appState,
transcriptEntries: [],
},
session,
engineV2: true,
authFlow: {
refreshOAuthProviderModels: vi.fn(async () => undefined),
},
harness: {
getConfig: vi.fn(async () => ({})),
},
setAppState: vi.fn((patch) => Object.assign(appState, patch)),
mountEditorReplacement: vi.fn(),
restoreEditor: vi.fn(),
showStatus: vi.fn(),
showError: vi.fn(),
showNotice: vi.fn(),
track: vi.fn(),
} as unknown as SlashCommandHost & {
mountEditorReplacement: ReturnType<typeof vi.fn>;
showStatus: ReturnType<typeof vi.fn>;
showError: ReturnType<typeof vi.fn>;
};
return { host, session, appState };
}

function mountedPicker(host: { mountEditorReplacement: ReturnType<typeof vi.fn> }): PickerOptions {
expect(host.mountEditorReplacement).toHaveBeenCalledOnce();
const component = host.mountEditorReplacement.mock.calls[0]![0];
expect(component).toBeInstanceOf(TabbedModelSelectorComponent);
return (component as unknown as { opts: PickerOptions }).opts;
}

describe('handleModelCommand', () => {
it('restores the context counters zeroed by a provider logout', async () => {
const { host, session, appState } = makeHost();

await handleModelCommand(host, '');
mountedPicker(host).onSessionOnlySelect({ alias: 'g1', thinking: 'off' });

await vi.waitFor(() => {
expect(host.showStatus).toHaveBeenCalled();
});
expect(session.setModel).toHaveBeenCalledWith('g1');
expect(appState).toMatchObject({
model: 'g1',
thinkingEffort: 'off',
contextTokens: 10,
maxContextTokens: 100,
contextUsage: 0.1,
});
expect(host.showError).not.toHaveBeenCalled();
});
});
52 changes: 48 additions & 4 deletions apps/kimi-code/test/tui/kimi-tui-startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1904,7 +1904,7 @@ describe('KimiTUI startup', () => {
}
});

it('tracks logout after managed credentials and session state are cleared', async () => {
it('keeps the session and clears model state when logging out the current provider', async () => {
const session = makeSession();
const harness = makeHarness(session, {
getConfig: vi.fn(async () => ({
Expand All @@ -1926,17 +1926,21 @@ describe('KimiTUI startup', () => {

await expect(driver.init()).resolves.toBe(false);
harness.track.mockClear();
const showStatus = vi.spyOn(driver as any, 'showStatus').mockImplementation(() => {});

vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code');
await handleLogoutCommand(driver as any);

expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code');
expect(session.close).toHaveBeenCalledOnce();
expect(session.close).not.toHaveBeenCalled();
expect(driver.state.appState).toMatchObject({
sessionId: '',
sessionId: 'ses-1',
model: '',
sessionTitle: null,
});
expect(showStatus).toHaveBeenCalledWith(
'Logged out from Kimi Code. Current model is unavailable — /login or /model to continue.',
'warning',
);
expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'managed:kimi-code' });
});

Expand Down Expand Up @@ -1981,6 +1985,46 @@ describe('KimiTUI startup', () => {
expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'openai' });
});

it('restores the retained session counters when logging back in after logout', async () => {
const session = makeSession();
const harness = makeHarness(session, {
getConfig: vi.fn(async () => ({
defaultModel: 'k2',
models: {
k2: { provider: 'managed:kimi-code', model: 'moonshot-v1', maxContextSize: 100 },
},
providers: { 'managed:kimi-code': { type: 'kimi' } },
})),
auth: {
status: vi.fn(async () => ({
providers: [{ providerName: 'managed:kimi-code', hasToken: true }],
})),
login: vi.fn(async () => {}),
logout: vi.fn(),
getManagedUsage: vi.fn(),
},
});
const driver = makeDriver(harness, makeStartupInput());

await expect(driver.init()).resolves.toBe(false);

vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code');
await handleLogoutCommand(driver as any);
expect(driver.state.appState).toMatchObject({ model: '', contextTokens: 0 });

vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code');
await handleLoginCommand(driver as any);

expect(session.setModel).toHaveBeenCalledWith('k2');
expect(driver.state.appState).toMatchObject({
sessionId: 'ses-1',
model: 'k2',
contextTokens: 10,
maxContextTokens: 100,
contextUsage: 0.1,
});
});

it('can log out a stale managed entry even after the OAuth token is gone', async () => {
const session = makeSession();
const harness = makeHarness(session, {
Expand Down
Loading