diff --git a/.changeset/managed-provider-kimi-code.md b/.changeset/managed-provider-kimi-code.md new file mode 100644 index 00000000..d48b9c0f --- /dev/null +++ b/.changeset/managed-provider-kimi-code.md @@ -0,0 +1,8 @@ +--- +"@pythoughts/pythinker-code": minor +"@pythoughts/pythinker-code-sdk": minor +--- + +Name the managed OAuth provider after the platform that serves it. It is reached over `auth.kimi.com` and `api.kimi.com`, but it was registered as `managed:pythinker-code`, which read as a first-party service in a client that talks to several providers. The provider id is now `managed:kimi-code`, its models are aliased `kimi-code/*`, and its credentials are stored under `oauth/kimi-code`. + +This is a breaking change for an existing config: the previous entries are not rewritten, so run `pythinker login` once to provision the managed provider under its current name, then remove the stale `managed:pythinker-code` entry. diff --git a/.changeset/session-free-skill-listing.md b/.changeset/session-free-skill-listing.md new file mode 100644 index 00000000..f09bb5df --- /dev/null +++ b/.changeset/session-free-skill-listing.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": minor +--- + +Resolve a workspace's skills without opening a session, so an editor panel can list them before its first message. diff --git a/.changeset/shared-catalog-provider-import.md b/.changeset/shared-catalog-provider-import.md new file mode 100644 index 00000000..1aeb4b34 --- /dev/null +++ b/.changeset/shared-catalog-provider-import.md @@ -0,0 +1,6 @@ +--- +"@pythoughts/pythinker-code-sdk": minor +"@pythoughts/pythinker-code": patch +--- + +Add an SDK routine that imports a catalog provider and its models into the persisted config, and use it for the CLI provider import so both entry points preserve existing defaults the same way. diff --git a/.changeset/tui-render-gate.md b/.changeset/tui-render-gate.md new file mode 100644 index 00000000..1b5a0e5e --- /dev/null +++ b/.changeset/tui-render-gate.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Stop the fixed-layout TUI anchoring its first frames to the shell cursor, which pushed the panel border into scrollback. diff --git a/.changeset/vscode-chat-logo-and-slash-insert.md b/.changeset/vscode-chat-logo-and-slash-insert.md new file mode 100644 index 00000000..b640cc26 --- /dev/null +++ b/.changeset/vscode-chat-logo-and-slash-insert.md @@ -0,0 +1,5 @@ +--- +"pythinker-code": patch +--- + +Show the assistant logo beside replies without breaking the step timeline, complete a picked slash command in the input instead of sending it on its own, ship a decodable Marketplace icon, and retry transient registry failures when publishing. diff --git a/README.md b/README.md index d402d85d..6896e936 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@
[![npm version](https://img.shields.io/npm/v/@pythoughts/pythinker-code?style=for-the-badge&logo=npm&logoColor=white&color=CB3837&label=pythinker-code)](https://www.npmjs.com/package/@pythoughts/pythinker-code) -[![Downloads](https://img.shields.io/npm/dm/@pythoughts/pythinker-code?style=for-the-badge&logo=npm&logoColor=white&color=16a34a&label=downloads)](https://www.npmjs.com/package/@pythoughts/pythinker-code) +[![Downloads](https://img.shields.io/npm/dm/@pythoughts/pythinker-code?style=for-the-badge&logo=npm&logoColor=white&color=2b89ff&label=downloads)](https://www.npmjs.com/package/@pythoughts/pythinker-code) [![Node.js](https://img.shields.io/badge/Node.js-26%2B-339933?style=for-the-badge&logo=nodedotjs&logoColor=white)](https://github.com/Pythoughts-labs/pythinker-code/blob/main/package.json) [![License: MIT](https://img.shields.io/badge/License-MIT-16a34a.svg?style=for-the-badge)](https://github.com/Pythoughts-labs/pythinker-code/blob/main/LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/Pythoughts-labs/pythinker-code/ci.yml?branch=main&label=CI&style=for-the-badge&logo=githubactions&logoColor=white)](https://github.com/Pythoughts-labs/pythinker-code/actions/workflows/ci.yml?query=branch%3Amain) diff --git a/apps/pythinker-code/package.json b/apps/pythinker-code/package.json index b1a92a3c..448d1b28 100644 --- a/apps/pythinker-code/package.json +++ b/apps/pythinker-code/package.json @@ -95,7 +95,7 @@ "web-tree-sitter": "0.25.10" }, "devDependencies": { - "@earendil-works/pi-tui": "^0.81.1", + "@earendil-works/pi-tui": "^0.83.0", "@pythoughts/acp-adapter": "workspace:^", "@pythoughts/dashboard-server": "workspace:^", "@pythoughts/dashboard-web": "workspace:*", diff --git a/apps/pythinker-code/src/cli/sub/provider.ts b/apps/pythinker-code/src/cli/sub/provider.ts index 79899f11..33a8c440 100644 --- a/apps/pythinker-code/src/cli/sub/provider.ts +++ b/apps/pythinker-code/src/cli/sub/provider.ts @@ -17,17 +17,16 @@ import { CustomRegistryApiError, fetchCustomRegistry, type CustomRegistrySource, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '@pythoughts/pythinker-code-oauth'; import { - applyCatalogProvider, - catalogBaseUrl, catalogConnectionWire, catalogProviderModels, CatalogFetchError, createPythinkerHarness, DEFAULT_CATALOG_URL, fetchCatalog, + importCatalogProvider, inferWireType, type Catalog, type CatalogProviderEntry, @@ -366,74 +365,20 @@ export async function handleCatalogAdd( } const harness = deps.getHarness(); - await harness.ensureConfigFile(); - - let config = await harness.getConfig(); - - // Capture defaults BEFORE `removeProvider`, because that call clears - // `defaultModel` when it points at one of this provider's aliases (see - // `core-impl.ts removePythinkerProvider`). Without this, re-importing an - // already-configured provider would lose the user's previously-set default - // even when `--default-model` is not supplied. - const previousDefaultProvider = config.defaultProvider; - const previousDefaultModel = config.defaultModel; - const previousDefaultThinking = config.defaultThinking; - - if (config.providers[providerId] !== undefined) { - config = await harness.removeProvider(providerId); - } - - const baseUrl = catalogBaseUrl(entry, wire); - // `applyCatalogProvider` always overwrites both `defaultModel` and - // `defaultThinking`. The values we pass here are temporary; we restore - // a consistent state in the post-apply block below. - applyCatalogProvider(config, { - providerId, - catalogUrl: url, - wire, - baseUrl, - apiKey: useEnvVar ? undefined : literalApiKey, - apiKeyEnvVar: useEnvVar ? apiKeyEnvVar : undefined, - models, - selectedModelId: opts.defaultModel ?? '', - thinking: false, - }); - - // Resolve the final `defaultModel`: - // - If the caller asked for one, `applyCatalogProvider` already set it. - // - Else, restore the previous default ONLY when its alias still resolves - // after the catalog refresh; the catalog may have dropped the old - // model, in which case restoring would point default_model at a - // non-existent alias and break the next session. - if (opts.defaultModel === undefined) { - const stillResolves = - previousDefaultModel !== undefined && - config.models?.[previousDefaultModel] !== undefined; - config.defaultModel = stillResolves ? previousDefaultModel : undefined; + try { + await importCatalogProvider(harness, { + providerId, + entry, + catalogUrl: url, + apiKey: useEnvVar ? undefined : literalApiKey, + apiKeyEnvVar: useEnvVar ? apiKeyEnvVar : undefined, + defaultModel: opts.defaultModel, + }); + } catch (error) { + deps.stderr.write(`${errorMessage(error)}\n`); + deps.exit(1); } - // Mirror the defaultModel handling for defaultProvider: `removeProvider` - // cleared it, and the catalog refresh may have dropped the provider id. - config.defaultProvider = - previousDefaultProvider !== undefined && config.providers[previousDefaultProvider] !== undefined - ? previousDefaultProvider - : undefined; - - // Always restore `defaultThinking` from what was there before — including - // `undefined`. Persisting `false` when the user never set it would make - // `resolveThinkingLevel` (agent-core/src/agent/config/thinking.ts) treat - // it as an explicit "off" request and silently disable thinking, even - // for thinking-capable models. - config.defaultThinking = previousDefaultThinking; - - await harness.setConfig({ - providers: config.providers, - models: config.models, - defaultProvider: config.defaultProvider, - defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, - }); - const displayName = entry.name ?? providerId; deps.stdout.write( `Imported ${displayName} (${providerId}) with ${String(models.length)} model${models.length === 1 ? '' : 's'} from ${url}.\n`, @@ -572,8 +517,8 @@ function resolveApiKey(flag: string | undefined, env: NodeJS.ProcessEnv): string return undefined; } -function asManaged(config: PythinkerConfig): ManagedPythinkerConfigShape { - return config as unknown as ManagedPythinkerConfigShape; +function asManaged(config: PythinkerConfig): ManagedKimiConfigShape { + return config as unknown as ManagedKimiConfigShape; } function providerSourceLabel(provider: PythinkerConfig['providers'][string]): string { diff --git a/apps/pythinker-code/src/cli/telemetry.ts b/apps/pythinker-code/src/cli/telemetry.ts index 4111050b..8fcb2d45 100644 --- a/apps/pythinker-code/src/cli/telemetry.ts +++ b/apps/pythinker-code/src/cli/telemetry.ts @@ -1,4 +1,4 @@ -import { createPythinkerDeviceId, PYTHINKER_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; +import { createPythinkerDeviceId, KIMI_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; import { PythinkerAuthFacade, loadRuntimeConfigSafe, @@ -55,7 +55,7 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): uiMode: options.uiMode, model: options.model ?? options.config.defaultModel, getAccessToken: async () => - (await options.harness.auth.getCachedAccessToken(PYTHINKER_CODE_PROVIDER_NAME)) ?? null, + (await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, }); if (options.bootstrap.firstLaunch) { options.harness.track('first_launch'); @@ -102,7 +102,7 @@ export function initializeServerTelemetry( version: options.version, uiMode: WEB_UI_MODE, model: config.defaultModel, - getAccessToken: async () => (await auth.getCachedAccessToken(PYTHINKER_CODE_PROVIDER_NAME)) ?? null, + getAccessToken: async () => (await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, }); return { diff --git a/apps/pythinker-code/src/constant/app.ts b/apps/pythinker-code/src/constant/app.ts index 89a88ecb..5a1e0dc7 100644 --- a/apps/pythinker-code/src/constant/app.ts +++ b/apps/pythinker-code/src/constant/app.ts @@ -34,7 +34,7 @@ export const PYTHINKER_CODE_BANNER_DIR_NAME = 'banner'; export const PYTHINKER_CODE_BANNER_STATE_FILE_NAME = 'state.json'; // Managed Pythinker auth provider key shared with OAuth/SDK config. -export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:pythinker-code'; +export { KIMI_CODE_PROVIDER_NAME as DEFAULT_OAUTH_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; // SDK/core error code that tells the TUI to show a login-required startup // notice. Derived from sdk's ErrorCodes so a future rename in core diff --git a/apps/pythinker-code/src/tui/commands/auth.ts b/apps/pythinker-code/src/tui/commands/auth.ts index e9c81c49..44627f09 100644 --- a/apps/pythinker-code/src/tui/commands/auth.ts +++ b/apps/pythinker-code/src/tui/commands/auth.ts @@ -10,8 +10,8 @@ import { OpenAICodexApiError, OpenPlatformApiError, runOpenAICodexOAuthFlow, - type ManagedPythinkerCodeModelInfo, - type ManagedPythinkerConfigShape, + type ManagedKimiCodeModelInfo, + type ManagedKimiConfigShape, type OpenPlatformDefinition, } from '@pythoughts/pythinker-code-oauth'; import { @@ -54,7 +54,7 @@ export async function handleLoginCommand(host: SlashCommandHost): Promise return; } - if (platformId === 'pythinker-code') { + if (platformId === 'kimi-code') { await handlePythinkerCodeOAuthLogin(host); return; } @@ -156,7 +156,7 @@ async function handleOpenPlatformLogin( }; host.cancelInFlight = cancelLogin; - let models: ManagedPythinkerCodeModelInfo[]; + let models: ManagedKimiCodeModelInfo[]; try { models = await fetchOpenPlatformModels(platform, apiKey, fetch, controller.signal); models = filterModelsByPrefix(models, platform); @@ -193,7 +193,7 @@ async function handleOpenPlatformLogin( } const config = await host.harness.getConfig(); - applyOpenPlatformConfig(config as ManagedPythinkerConfigShape, { + applyOpenPlatformConfig(config as ManagedKimiConfigShape, { platform, models, selectedModel: selection.model, @@ -358,7 +358,7 @@ async function handleOpenAICodexOAuthLogin(host: SlashCommandHost): Promise { +): Promise<{ model: ManagedKimiCodeModelInfo; effort: string } | undefined> { const modelDict: Record = {}; for (const m of models) { modelDict[`${platform.id}/${m.id}`] = { diff --git a/apps/pythinker-code/src/tui/commands/provider.ts b/apps/pythinker-code/src/tui/commands/provider.ts index 7e0d5073..ff1dbde5 100644 --- a/apps/pythinker-code/src/tui/commands/provider.ts +++ b/apps/pythinker-code/src/tui/commands/provider.ts @@ -2,7 +2,7 @@ import { applyCustomRegistryEntries, fetchCustomRegistry, type CustomRegistrySource, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '@pythoughts/pythinker-code-oauth'; import { CatalogFetchError, @@ -212,7 +212,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise try { const config = await host.harness.getConfig(); applyCustomRegistryEntries( - config as unknown as ManagedPythinkerConfigShape, + config as unknown as ManagedKimiConfigShape, entries, source, ); diff --git a/apps/pythinker-code/src/tui/commands/skills.ts b/apps/pythinker-code/src/tui/commands/skills.ts index e3cbd350..6452df50 100644 --- a/apps/pythinker-code/src/tui/commands/skills.ts +++ b/apps/pythinker-code/src/tui/commands/skills.ts @@ -1,56 +1,16 @@ -import type { Session, SkillSummary } from '@pythoughts/pythinker-code-sdk'; +import { + buildSkillSlashCommands, + isUserActivatableSkill, + type Session, +} from '@pythoughts/pythinker-code-sdk'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; import type { SlashCommandHost } from './dispatch'; -import type { PythinkerSlashCommand } from './types'; export type SkillListSession = Pick; -export interface SkillSlashCommands { - readonly commands: readonly PythinkerSlashCommand[]; - readonly commandMap: ReadonlyMap; -} - -export function isUserActivatableSkill(skill: SkillSummary): boolean { - return ( - skill.userInvocable !== false && - (skill.type === undefined || - skill.type === 'prompt' || - skill.type === 'inline' || - skill.type === 'flow') - ); -} - -function compareSkillSlashCommands(a: SkillSummary, b: SkillSummary): number { - return ( - getSkillSlashCommandGroup(a.source) - getSkillSlashCommandGroup(b.source) || - a.name.localeCompare(b.name) - ); -} - -function getSkillSlashCommandGroup(source: SkillSummary['source']): number { - return source === 'builtin' ? 0 : 1; -} - -export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillSlashCommands { - const commandMap = new Map(); - const sortedSkills = [...skills].toSorted(compareSkillSlashCommands); - const commands = sortedSkills.filter(isUserActivatableSkill).map((skill) => { - const commandName = - skill.commandName ?? - (skill.source === 'builtin' || skill.isSubSkill === true - ? skill.name - : `skill:${skill.name}`); - commandMap.set(commandName, skill.name); - return { - name: commandName, - aliases: [], - description: skill.description ?? '', - argumentHint: skill.argumentHint, - }; - }); - return { commands, commandMap }; -} +export type { SkillSlashCommands } from '@pythoughts/pythinker-code-sdk'; +export { buildSkillSlashCommands, isUserActivatableSkill }; export async function handleSkillsCommand( host: SlashCommandHost, diff --git a/apps/pythinker-code/src/tui/components/chrome/pythinker-logo.ts b/apps/pythinker-code/src/tui/components/chrome/pythinker-logo.ts index 5ede63eb..ef036139 100644 --- a/apps/pythinker-code/src/tui/components/chrome/pythinker-logo.ts +++ b/apps/pythinker-code/src/tui/components/chrome/pythinker-logo.ts @@ -43,6 +43,13 @@ export const LOGO_EYE_ROW = 3; export const LOGO_LEFT_EYE_COL = 4; export const LOGO_RIGHT_EYE_COL = 8; +/** Antenna bulb cell on row 0. */ +export const LOGO_ANTENNA_ROW = 0; +export const LOGO_ANTENNA_COL = 6; + +/** Antenna spin frames — half-shaded circles read as a clockwise rotation. */ +export const ANTENNA_SPINNER_FRAMES = ['◐', '◓', '◑', '◒'] as const; + /** Eye phases from the installer `_blink_eyes` / `Blink-Eye` sequence. */ export type EyeBlinkPhase = 'open' | 'glance' | 'closed' | 'open-shine'; @@ -157,10 +164,29 @@ export function renderPythinkerLogoEyeRow(state: LogoEyeBlinkState): string { return segment(line, ranges); } -export function renderPythinkerLogoWithEyes(state: LogoEyeBlinkState = LOGO_EYES_OPEN): string[] { - return PYTHINKER_LOGO_LINES.map((_, index) => - index === LOGO_EYE_ROW ? renderPythinkerLogoEyeRow(state) : renderPythinkerLogoLine(index), - ); +/** Paint the antenna row with a spinner frame in place of the static bulb. */ +export function renderPythinkerLogoAntennaRow(frameIndex: number): string { + const frame = + ANTENNA_SPINNER_FRAMES[frameIndex % ANTENNA_SPINNER_FRAMES.length] ?? '●'; + const chars = Array.from(PYTHINKER_LOGO_LINES[LOGO_ANTENNA_ROW]); + chars[LOGO_ANTENNA_COL] = frame; + const line = chars.join(''); + return segment(line, [ + [LOGO_ANTENNA_COL, LOGO_ANTENNA_COL + frame.length, antenna], + ]); +} + +export function renderPythinkerLogoWithEyes( + state: LogoEyeBlinkState = LOGO_EYES_OPEN, + antennaFrame?: number, +): string[] { + return PYTHINKER_LOGO_LINES.map((_, index) => { + if (index === LOGO_EYE_ROW) return renderPythinkerLogoEyeRow(state); + if (index === LOGO_ANTENNA_ROW && antennaFrame !== undefined) { + return renderPythinkerLogoAntennaRow(antennaFrame); + } + return renderPythinkerLogoLine(index); + }); } /** Paint one logo row with SVG-accurate brand colors. */ diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts b/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts index 59dc6902..949ed93a 100644 --- a/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts +++ b/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts @@ -225,7 +225,7 @@ function renderTipsBlock( if (withRule) { const ruleChar = asciiMode ? '-' : '─'; - lines.push(muted(ruleChar.repeat(Math.max(4, width)))); + lines.push(muted(ruleChar.repeat(tipWidth))); } for (const item of tips) { diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome-logo-animation.ts b/apps/pythinker-code/src/tui/components/chrome/welcome-logo-animation.ts index ac442b2f..5e473e11 100644 --- a/apps/pythinker-code/src/tui/components/chrome/welcome-logo-animation.ts +++ b/apps/pythinker-code/src/tui/components/chrome/welcome-logo-animation.ts @@ -1,10 +1,13 @@ /** - * Welcome-banner eye blink — same phases as install.ps1 `Blink-Eye` / install.sh - * `_blink_eyes`, with longer holds so the closed eye registers in the TUI. + * Welcome-banner animation — eye blink with the same phases as install.ps1 + * `Blink-Eye` / install.sh `_blink_eyes` (longer holds so the closed eye + * registers in the TUI), plus a one-shot antenna spin while the banner + * first loads. */ import { asciiGlyphsEnabled } from './welcome-banner'; import { + ANTENNA_SPINNER_FRAMES, LOGO_EYES_OPEN, type EyeBlinkPhase, type LogoEyeBlinkState, @@ -33,17 +36,28 @@ export function welcomeLogoAnimationEnabled(): boolean { return true; } -export interface WelcomeEyeAnimationHost { +export interface WelcomeLogoAnimationHost { setEyeBlinkState(state: LogoEyeBlinkState): void; + /** `null` restores the static ● bulb. */ + setAntennaFrame(frame: number | null): void; } -export class WelcomeLogoEyeAnimator { +/** Idle time between blink sequences. */ +export const WELCOME_BLINK_INTERVAL_MS = 5000; + +/** Antenna spin cadence and total length — one spin at banner load. */ +export const WELCOME_ANTENNA_SPIN_TICK_MS = 120; +export const WELCOME_ANTENNA_SPIN_DURATION_MS = 6000; + +export class WelcomeLogoAnimator { private eyeState: LogoEyeBlinkState = LOGO_EYES_OPEN; - private timer: ReturnType | null = null; + private antennaFrameIndex = 0; + private blinkTimer: ReturnType | null = null; + private spinTimer: ReturnType | null = null; private disposed = false; constructor( - private readonly host: WelcomeEyeAnimationHost, + private readonly host: WelcomeLogoAnimationHost, private readonly requestRender: () => void, ) {} @@ -53,20 +67,58 @@ export class WelcomeLogoEyeAnimator { start(): void { if (!welcomeLogoAnimationEnabled() || this.disposed) return; + this.spinAntenna(0); + this.playBlink(); + } + + private playBlink(): void { this.runEye('left', 0, () => { this.runEye('right', 0, () => { this.applyState(LOGO_EYES_OPEN); + this.scheduleNextBlink(); }); }); } + private scheduleNextBlink(): void { + if (this.disposed) return; + this.blinkTimer = setTimeout(() => { + this.blinkTimer = null; + this.playBlink(); + }, WELCOME_BLINK_INTERVAL_MS); + } + + private spinAntenna(elapsedMs: number): void { + if (this.disposed) return; + if (elapsedMs >= WELCOME_ANTENNA_SPIN_DURATION_MS) { + this.applyAntennaFrame(null); + return; + } + this.applyAntennaFrame(this.antennaFrameIndex); + this.antennaFrameIndex = (this.antennaFrameIndex + 1) % ANTENNA_SPINNER_FRAMES.length; + this.spinTimer = setTimeout(() => { + this.spinTimer = null; + this.spinAntenna(elapsedMs + WELCOME_ANTENNA_SPIN_TICK_MS); + }, WELCOME_ANTENNA_SPIN_TICK_MS); + } + + private applyAntennaFrame(frame: number | null): void { + this.host.setAntennaFrame(frame); + this.requestRender(); + } + dispose(): void { this.disposed = true; - if (this.timer !== null) { - clearTimeout(this.timer); - this.timer = null; + if (this.blinkTimer !== null) { + clearTimeout(this.blinkTimer); + this.blinkTimer = null; + } + if (this.spinTimer !== null) { + clearTimeout(this.spinTimer); + this.spinTimer = null; } this.applyState(LOGO_EYES_OPEN); + this.applyAntennaFrame(null); } private runEye(side: 'left' | 'right', stepIndex: number, done: () => void): void { @@ -84,8 +136,8 @@ export class WelcomeLogoEyeAnimator { this.runEye(side, stepIndex + 1, done); return; } - this.timer = setTimeout(() => { - this.timer = null; + this.blinkTimer = setTimeout(() => { + this.blinkTimer = null; this.runEye(side, stepIndex + 1, done); }, step.delayMs); } diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome.ts b/apps/pythinker-code/src/tui/components/chrome/welcome.ts index 998f8cd6..9d145d38 100644 --- a/apps/pythinker-code/src/tui/components/chrome/welcome.ts +++ b/apps/pythinker-code/src/tui/components/chrome/welcome.ts @@ -19,9 +19,9 @@ import { type LogoEyeBlinkState, } from './pythinker-logo'; import { - WelcomeLogoEyeAnimator, + WelcomeLogoAnimator, welcomeLogoAnimationEnabled, - type WelcomeEyeAnimationHost, + type WelcomeLogoAnimationHost, } from './welcome-logo-animation'; import { asciiGlyphsEnabled, @@ -32,11 +32,12 @@ import { renderWelcomeBanner, } from './welcome-banner'; -export class WelcomeComponent implements Component, WelcomeEyeAnimationHost { +export class WelcomeComponent implements Component, WelcomeLogoAnimationHost { private state: AppState; private readonly gitCache: GitStatusCache; private eyeBlinkState: LogoEyeBlinkState = LOGO_EYES_OPEN; - private eyeAnimator: WelcomeLogoEyeAnimator | null = null; + private antennaFrame: number | null = null; + private eyeAnimator: WelcomeLogoAnimator | null = null; constructor( state: AppState, @@ -45,7 +46,7 @@ export class WelcomeComponent implements Component, WelcomeEyeAnimationHost { this.state = state; this.gitCache = createWelcomeGitCache(state.workDir); if (requestRender !== undefined && welcomeLogoAnimationEnabled() && !isRainbowColorActive()) { - this.eyeAnimator = new WelcomeLogoEyeAnimator(this, requestRender); + this.eyeAnimator = new WelcomeLogoAnimator(this, requestRender); queueMicrotask(() => this.eyeAnimator?.start()); } } @@ -54,6 +55,10 @@ export class WelcomeComponent implements Component, WelcomeEyeAnimationHost { this.eyeBlinkState = state; } + setAntennaFrame(frame: number | null): void { + this.antennaFrame = frame; + } + invalidate(): void {} dispose(): void { @@ -69,7 +74,7 @@ export class WelcomeComponent implements Component, WelcomeEyeAnimationHost { const logoLines = isRainbowColorActive() ? renderRainbowWelcomeLogo() - : renderPythinkerLogoWithEyes(this.eyeBlinkState); + : renderPythinkerLogoWithEyes(this.eyeBlinkState, this.antennaFrame ?? undefined); return renderWelcomeBanner({ width, diff --git a/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts index f8b4ce90..ef44051f 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts @@ -58,7 +58,7 @@ export function modelDisplayName(alias: string, model: ModelAlias | undefined): } export function providerDisplayName(provider: string): string { - if (provider === DEFAULT_OAUTH_PROVIDER_NAME) return 'Pythinker'; + if (provider === DEFAULT_OAUTH_PROVIDER_NAME) return 'Kimi'; if (provider.startsWith('managed:')) return provider.slice('managed:'.length); return provider; } diff --git a/apps/pythinker-code/src/tui/components/dialogs/platform-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/platform-selector.ts index f450ec10..e546d197 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/platform-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/platform-selector.ts @@ -44,9 +44,9 @@ function buildPlatformOptions(catalog: Catalog): readonly ChoiceOption[] { label: OPENAI_CODEX_OAUTH_LOGIN.name, description: 'OAuth', }, - { value: 'pythinker-code', label: 'Pythinker (OAuth)', description: 'OAuth' }, + { value: 'kimi-code', label: 'Kimi (OAuth)', description: 'OAuth' }, ]; - const seen = new Set(['pythinker-code', OPENAI_CODEX_OAUTH_LOGIN.id]); + const seen = new Set(['kimi-code', OPENAI_CODEX_OAUTH_LOGIN.id]); for (const featured of FEATURED_CATALOG_PROVIDERS) { const entry = catalog[featured.id]; diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 941d6c02..29071aa8 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -468,9 +468,14 @@ export class PythinkerTUI { return; } - const shouldReplayHistory = await this.initMainTui(); + // Start the loop before mounting anything: pi-tui paints on invalidate + // even before ui.start(), so mounting first anchors early frames to the + // shell cursor and startEventLoop's scroll-to-home would push the live + // frame's top rows into scrollback for good. Same order as the + // migration branch above. this.startEventLoop(); try { + const shouldReplayHistory = await this.initMainTui(); this.startBackgroundFdAutocomplete(); await this.finishStartup(shouldReplayHistory); this.startKeybindingsWatcher(); @@ -550,6 +555,14 @@ export class PythinkerTUI { private startEventLoop(): void { const start = (): void => { + // The fixed layout emits exactly `terminal.rows` lines per frame, and + // pi-tui's first render assumes a clean screen. Any shell output already + // on screen would scroll the frame's top rows (panel border included) + // out of view for good, so scroll the history up first and start at home. + if (this.state.layout === 'fixed') { + const rows = this.state.terminal.rows; + this.presentation.writeTerminalControl('\n'.repeat(Math.max(1, rows)) + '\u001B[H'); + } this.presentation.start(() => { this.refreshFooter(); this.state.ui.requestRender(); diff --git a/apps/pythinker-code/src/tui/tui-state.ts b/apps/pythinker-code/src/tui/tui-state.ts index 546515b1..65be7ecb 100644 --- a/apps/pythinker-code/src/tui/tui-state.ts +++ b/apps/pythinker-code/src/tui/tui-state.ts @@ -71,6 +71,11 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { const terminal = new ProcessTerminal(); const ui = new TUI(terminal); + // Gate rendering until the event loop starts: pi-tui paints on requestRender + // even before ui.start() (stopped defaults to false), so construction-time + // renders would anchor frames to the shell cursor. The field is private in + // pi-tui's types; ui.start() flips it back to false. + (ui as unknown as { stopped: boolean }).stopped = true; const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); diff --git a/apps/pythinker-code/src/tui/utils/refresh-providers.ts b/apps/pythinker-code/src/tui/utils/refresh-providers.ts index f273fd8e..50a45303 100644 --- a/apps/pythinker-code/src/tui/utils/refresh-providers.ts +++ b/apps/pythinker-code/src/tui/utils/refresh-providers.ts @@ -1,22 +1,22 @@ import { - PYTHINKER_CODE_PLATFORM_ID, - PYTHINKER_CODE_PROVIDER_NAME, + KIMI_CODE_PLATFORM_ID, + KIMI_CODE_PROVIDER_NAME, OPENAI_CODEX_PROVIDER_ID, - applyManagedPythinkerCodeConfig, + applyManagedKimiCodeConfig, applyOpenAICodexOAuthConfig, applyOpenPlatformConfig, applyCustomRegistryProvider, fetchCustomRegistry, - fetchManagedPythinkerCodeModels, + fetchManagedKimiCodeModels, fetchOpenAICodexModels, fetchOpenPlatformModels, filterModelsByPrefix, getOpenPlatformById, isOpenPlatformId, removeCustomRegistryProvider, - resolvePythinkerCodeRuntimeAuth, + resolveKimiCodeRuntimeAuth, type CustomRegistrySource, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '@pythoughts/pythinker-code-oauth'; import { applyCatalogProvider, @@ -116,8 +116,8 @@ async function fetchCustomRegistryFromSources( throw new Error('No custom registry sources configured.'); } -function asManaged(config: PythinkerConfig): ManagedPythinkerConfigShape { - return config as unknown as ManagedPythinkerConfigShape; +function asManaged(config: PythinkerConfig): ManagedKimiConfigShape { + return config as unknown as ManagedKimiConfigShape; } function collectModelIdsForAliases(config: PythinkerConfig, aliasKeys: ReadonlySet): Set { @@ -341,25 +341,25 @@ export async function refreshAllProviderModels( // ------------------------------------------------------------------------- // 1. Managed Pythinker Code (OAuth) // ------------------------------------------------------------------------- - const managedProvider = config.providers[PYTHINKER_CODE_PROVIDER_NAME]; + const managedProvider = config.providers[KIMI_CODE_PROVIDER_NAME]; if ( managedProvider !== undefined && managedProvider.type === 'pythinker' && managedProvider.oauth !== undefined ) { try { - const auth = resolvePythinkerCodeRuntimeAuth({ + const auth = resolveKimiCodeRuntimeAuth({ configuredBaseUrl: managedProvider.baseUrl, configuredOAuthRef: managedProvider.oauth, }); - const accessToken = await host.resolveOAuthToken(PYTHINKER_CODE_PROVIDER_NAME, auth.oauthRef); - const models = await fetchManagedPythinkerCodeModels({ + const accessToken = await host.resolveOAuthToken(KIMI_CODE_PROVIDER_NAME, auth.oauthRef); + const models = await fetchManagedKimiCodeModels({ accessToken, baseUrl: auth.baseUrl, }); if (models.length > 0) { const next = structuredClone(config); - applyManagedPythinkerCodeConfig(asManaged(next), { + applyManagedKimiCodeConfig(asManaged(next), { models, baseUrl: auth.baseUrl, oauthKey: auth.oauthRef.key, @@ -369,25 +369,25 @@ export async function refreshAllProviderModels( const refreshedAliasKeys = providerRefreshAliasKeys( config, next, - PYTHINKER_CODE_PROVIDER_NAME, - `${PYTHINKER_CODE_PLATFORM_ID}/`, + KIMI_CODE_PROVIDER_NAME, + `${KIMI_CODE_PLATFORM_ID}/`, ); restoreProviderAliases( next, - preserveUserProviderAliases(config, PYTHINKER_CODE_PROVIDER_NAME, refreshedAliasKeys), + preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), ); restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); clampDanglingDefault(next); clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - if (providerModelsEqual(config, next, PYTHINKER_CODE_PROVIDER_NAME, refreshedAliasKeys)) { - unchanged.push(PYTHINKER_CODE_PROVIDER_NAME); + if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { + unchanged.push(KIMI_CODE_PROVIDER_NAME); } else { const { added, removed } = computeChanges( collectModelIdsForAliases(config, refreshedAliasKeys), collectModelIdsForAliases(next, refreshedAliasKeys), ); - await host.removeProvider(PYTHINKER_CODE_PROVIDER_NAME); + await host.removeProvider(KIMI_CODE_PROVIDER_NAME); config = await host.setConfig({ providers: next.providers, models: next.models, @@ -395,7 +395,7 @@ export async function refreshAllProviderModels( defaultThinking: next.defaultThinking, }); changed.push({ - providerId: PYTHINKER_CODE_PROVIDER_NAME, + providerId: KIMI_CODE_PROVIDER_NAME, providerName: PRODUCT_NAME, added, removed, @@ -404,7 +404,7 @@ export async function refreshAllProviderModels( } } catch (error) { failed.push({ - provider: PYTHINKER_CODE_PROVIDER_NAME, + provider: KIMI_CODE_PROVIDER_NAME, reason: error instanceof Error ? error.message : String(error), }); } @@ -688,7 +688,7 @@ export async function refreshAllProviderModels( } >(); for (const [providerId, providerConfig] of Object.entries(config.providers)) { - if (providerId === PYTHINKER_CODE_PROVIDER_NAME) continue; + if (providerId === KIMI_CODE_PROVIDER_NAME) continue; if (providerId === OPENAI_CODEX_PROVIDER_ID) continue; if (isOpenPlatformId(providerId)) continue; const source = readCustomRegistrySource(providerConfig); diff --git a/apps/pythinker-code/test/cli/export.test.ts b/apps/pythinker-code/test/cli/export.test.ts index 570fb592..9cf1bcf8 100644 --- a/apps/pythinker-code/test/cli/export.test.ts +++ b/apps/pythinker-code/test/cli/export.test.ts @@ -80,7 +80,7 @@ vi.mock('@pythoughts/pythinker-code-oauth', async () => { return { ...actual, createPythinkerDeviceId: mocks.createPythinkerDeviceId, - PYTHINKER_CODE_PROVIDER_NAME: 'pythinker-code', + KIMI_CODE_PROVIDER_NAME: 'pythinker-code', }; }); diff --git a/apps/pythinker-code/test/cli/provider.test.ts b/apps/pythinker-code/test/cli/provider.test.ts index 32df66a5..503ce52c 100644 --- a/apps/pythinker-code/test/cli/provider.test.ts +++ b/apps/pythinker-code/test/cli/provider.test.ts @@ -495,10 +495,10 @@ describe('pythinker provider list', () => { apiKey: 'k', source: { kind: 'apiJson', url: REGISTRY_URL, apiKey: 'k' }, }, - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, manual: { type: 'openai', baseUrl: 'https://y', apiKey: 'm' }, }, @@ -533,7 +533,7 @@ describe('pythinker provider list', () => { const out = stdout.join(''); expect(out).toMatch(/kohub\s+type=anthropic\s+models=2\s+source=apiJson\(/); - expect(out).toMatch(/managed:pythinker-code\s+type=pythinker\s+models=0\s+source=oauth/); + expect(out).toMatch(/managed:kimi-code\s+type=pythinker\s+models=0\s+source=oauth/); expect(out).toMatch(/manual\s+type=openai\s+models=1\s+source=inline/); expect(out).toContain('Default model: kohub/a'); }); @@ -559,7 +559,7 @@ describe('pythinker provider list', () => { }; expect(Object.keys(parsed.providers).toSorted()).toEqual([ 'kohub', - 'managed:pythinker-code', + 'managed:kimi-code', 'manual', ]); expect(Object.keys(parsed.models)).toContain('kohub/a'); diff --git a/apps/pythinker-code/test/cli/run-prompt.test.ts b/apps/pythinker-code/test/cli/run-prompt.test.ts index e9a77a67..95ee74fb 100644 --- a/apps/pythinker-code/test/cli/run-prompt.test.ts +++ b/apps/pythinker-code/test/cli/run-prompt.test.ts @@ -118,7 +118,7 @@ vi.mock('@pythoughts/pythinker-code-oauth', async () => { return { ...actual, createPythinkerDeviceId: mocks.createPythinkerDeviceId, - PYTHINKER_CODE_PROVIDER_NAME: 'pythinker-code', + KIMI_CODE_PROVIDER_NAME: 'pythinker-code', }; }); diff --git a/apps/pythinker-code/test/cli/run-shell.test.ts b/apps/pythinker-code/test/cli/run-shell.test.ts index 70d695b3..2b6aa6e9 100644 --- a/apps/pythinker-code/test/cli/run-shell.test.ts +++ b/apps/pythinker-code/test/cli/run-shell.test.ts @@ -111,7 +111,7 @@ vi.mock('@pythoughts/pythinker-code-oauth', async () => { return { ...actual, createPythinkerDeviceId: mocks.createPythinkerDeviceId, - PYTHINKER_CODE_PROVIDER_NAME: 'pythinker-code', + KIMI_CODE_PROVIDER_NAME: 'pythinker-code', }; }); diff --git a/apps/pythinker-code/test/cli/telemetry.test.ts b/apps/pythinker-code/test/cli/telemetry.test.ts index 3edd6910..50d2ebe8 100644 --- a/apps/pythinker-code/test/cli/telemetry.test.ts +++ b/apps/pythinker-code/test/cli/telemetry.test.ts @@ -31,7 +31,7 @@ vi.mock('@pythoughts/pythinker-telemetry', () => ({ vi.mock('@pythoughts/pythinker-code-oauth', () => ({ createPythinkerDeviceId: mocks.createPythinkerDeviceId, - PYTHINKER_CODE_PROVIDER_NAME: 'managed:pythinker-code', + KIMI_CODE_PROVIDER_NAME: 'managed:kimi-code', })); vi.mock('@pythoughts/pythinker-code-sdk', async (importOriginal) => { diff --git a/apps/pythinker-code/test/migration/migration-screen.test.ts b/apps/pythinker-code/test/migration/migration-screen.test.ts index 68fd7372..66a48495 100644 --- a/apps/pythinker-code/test/migration/migration-screen.test.ts +++ b/apps/pythinker-code/test/migration/migration-screen.test.ts @@ -455,7 +455,7 @@ describe('MigrationScreenComponent — result phase', () => { migratedHooks: 0, droppedHooks: 0, siblingContents: { - providers: ['openai', 'managed:pythinker-code'], + providers: ['openai', 'managed:kimi-code'], models: ['gpt4'], hooks: 3, }, diff --git a/apps/pythinker-code/test/tui/components/chrome/pythinker-logo.test.ts b/apps/pythinker-code/test/tui/components/chrome/pythinker-logo.test.ts index 1aa03eca..897a287c 100644 --- a/apps/pythinker-code/test/tui/components/chrome/pythinker-logo.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/pythinker-logo.test.ts @@ -3,11 +3,13 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + ANTENNA_SPINNER_FRAMES, buildLogoHeaderRows, LOGO_EYES_OPEN, PYTHINKER_LOGO_LINES, PYTHINKER_LOGO_WIDTH, renderPythinkerLogo, + renderPythinkerLogoAntennaRow, renderPythinkerLogoEyeRow, renderPythinkerLogoLine, renderPythinkerLogoWithEyes, @@ -68,6 +70,17 @@ describe('pythinker logo', () => { expect(visibleWidth(glance)).toBe(visibleWidth(PYTHINKER_LOGO_LINES[3])); }); + it('renders antenna spinner frames at stable width', () => { + for (let frame = 0; frame < ANTENNA_SPINNER_FRAMES.length; frame++) { + const row = renderPythinkerLogoAntennaRow(frame); + expect(row).toContain(ANTENNA_SPINNER_FRAMES[frame]); + expect(visibleWidth(row)).toBe(visibleWidth(PYTHINKER_LOGO_LINES[0])); + } + const lines = renderPythinkerLogoWithEyes(LOGO_EYES_OPEN, 2); + expect(lines).toHaveLength(5); + expect(lines[0]).toContain(ANTENNA_SPINNER_FRAMES[2]); + }); + it('keeps logo width stable across eye blink states', () => { const lines = renderPythinkerLogoWithEyes({ left: 'open-shine', right: 'closed' }); expect(lines).toHaveLength(5); diff --git a/apps/pythinker-code/test/tui/components/chrome/welcome-eye-animation.test.ts b/apps/pythinker-code/test/tui/components/chrome/welcome-eye-animation.test.ts index 7e7113bb..110de8e1 100644 --- a/apps/pythinker-code/test/tui/components/chrome/welcome-eye-animation.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/welcome-eye-animation.test.ts @@ -1,7 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; -import { WelcomeLogoEyeAnimator, welcomeLogoAnimationEnabled } from '#/tui/components/chrome/welcome-logo-animation'; +import { + WELCOME_ANTENNA_SPIN_DURATION_MS, + WELCOME_ANTENNA_SPIN_TICK_MS, + WELCOME_BLINK_INTERVAL_MS, + WelcomeLogoAnimator, + welcomeLogoAnimationEnabled, +} from '#/tui/components/chrome/welcome-logo-animation'; import { LOGO_EYES_OPEN } from '#/tui/components/chrome/pythinker-logo'; import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; import { setRainbowColors } from '#/tui/easter-eggs/rainbow-colors'; @@ -66,8 +72,9 @@ describe('WelcomeComponent eye animation', () => { setEyeBlinkState(state: typeof LOGO_EYES_OPEN) { states.push(`${state.left}:${state.right}`); }, + setAntennaFrame(_frame: number | null) {}, }; - const animator = new WelcomeLogoEyeAnimator(host, () => {}); + const animator = new WelcomeLogoAnimator(host, () => {}); animator.start(); expect(states[0]).toBe('glance:open'); @@ -79,6 +86,58 @@ describe('WelcomeComponent eye animation', () => { animator.dispose(); }); + it('repeats the blink every 5 seconds', () => { + const states: string[] = []; + const host = { + setEyeBlinkState(state: typeof LOGO_EYES_OPEN) { + states.push(`${state.left}:${state.right}`); + }, + setAntennaFrame(_frame: number | null) {}, + }; + const animator = new WelcomeLogoAnimator(host, () => {}); + animator.start(); + + const glanceCount = () => states.filter((s) => s === 'glance:open').length; + vi.advanceTimersByTime(1140); // one full two-eye blink sequence + expect(states.at(-1)).toBe('open:open'); + expect(glanceCount()).toBe(1); + + vi.advanceTimersByTime(WELCOME_BLINK_INTERVAL_MS); + expect(glanceCount()).toBe(2); + + vi.advanceTimersByTime(1140 + WELCOME_BLINK_INTERVAL_MS); + expect(glanceCount()).toBe(3); + animator.dispose(); + + const afterDispose = glanceCount(); + vi.advanceTimersByTime(WELCOME_BLINK_INTERVAL_MS + 2000); + expect(glanceCount()).toBe(afterDispose); + }); + + it('spins the antenna for six seconds on startup, then restores the bulb', () => { + const frames: (number | null)[] = []; + const host = { + setEyeBlinkState(_state: typeof LOGO_EYES_OPEN) {}, + setAntennaFrame(frame: number | null) { + frames.push(frame); + }, + }; + const animator = new WelcomeLogoAnimator(host, () => {}); + animator.start(); + + expect(frames[0]).toBe(0); + vi.advanceTimersByTime(WELCOME_ANTENNA_SPIN_TICK_MS * 3); + expect(frames).toEqual([0, 1, 2, 3]); + + vi.advanceTimersByTime(WELCOME_ANTENNA_SPIN_DURATION_MS); + expect(frames.at(-1)).toBeNull(); + + const settled = frames.length; + vi.advanceTimersByTime(WELCOME_ANTENNA_SPIN_DURATION_MS); + expect(frames.length).toBe(settled); + animator.dispose(); + }); + it('plays the installer blink sequence on startup', async () => { let renderCount = 0; let lastOutput = ''; diff --git a/apps/pythinker-code/test/tui/components/dialogs/effort-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/effort-selector.test.ts index b02966e5..05dd4400 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/effort-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/effort-selector.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; -const ANSI = /\[[0-9;]*m/g; +const ANSI = /\u001B\[[0-9;]*m/g; const strip = (s: string): string => s.replaceAll(ANSI, ''); const ESC = String.fromCodePoint(27); const DOWN = `${ESC}[B`; diff --git a/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts index 1e28fc24..2e05b222 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts @@ -6,7 +6,7 @@ import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector' import { parseKeybindingBlocks } from '#/tui/keybindings'; import { currentTheme } from '#/tui/theme'; -const ANSI = /\[[0-9;]*m/g; +const ANSI = /\u001B\[[0-9;]*m/g; const strip = (s: string): string => s.replaceAll(ANSI, ''); const ESC = String.fromCodePoint(27); const UP = `${ESC}[A`; @@ -20,7 +20,7 @@ function model( supportEfforts?: string[], ): ModelAlias { return { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: displayName.toLowerCase().replaceAll(' ', '-'), maxContextSize: 200_000, displayName, @@ -95,7 +95,7 @@ describe('ModelSelectorComponent', () => { const out = text(picker); // Model name on the left, provider on the right, with the current marker. - expect(out).toMatch(/❯ Kimi K2\s+Pythinker ← current/); + expect(out).toMatch(/❯ Kimi K2\s+Kimi ← current/); expect(out).not.toContain('Kimi K2 (Kimi)'); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts index dddcd722..41c4081a 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts @@ -59,7 +59,7 @@ describe('PlatformSelectorComponent', () => { const output = rendered(component); expect(output.indexOf('OpenAI Codex (OAuth)')).toBeLessThan( - output.indexOf('Pythinker (OAuth)'), + output.indexOf('Kimi (OAuth)'), ); expect(output).toContain('DeepSeek API'); expect(output).toContain('GLM Coding Plan'); diff --git a/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts b/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts index 93a300ff..092f3b7d 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts @@ -101,7 +101,7 @@ describe('ProviderManagerComponent', () => { const plain = component .render(120) .join('\n') - .replaceAll(/\[[0-9;]*m/g, ''); + .replaceAll(/\u001B\[[0-9;]*m/g, ''); expect(plain).toContain('← current'); expect(plain).not.toContain('●'); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index ec20c3b3..09969d91 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -34,7 +34,7 @@ function make(): { const onSelect = vi.fn(); const component = new TabbedModelSelectorComponent({ models: { - k2: model('Kimi K2', 'managed:pythinker-code'), + k2: model('Kimi K2', 'managed:kimi-code'), gpt: model('GPT-5', 'openai'), }, currentValue: 'k2', @@ -72,10 +72,10 @@ describe('TabbedModelSelectorComponent', () => { it('opens on the current model provider by default', () => { const { component } = make(); const out = strip(component.render(120).join('\n')); - expect(component.activeTabId()).toBe('managed:pythinker-code'); + expect(component.activeTabId()).toBe('managed:kimi-code'); expect(out).toContain('Kimi K2'); expect(out).not.toContain('GPT-5'); - expect(out).toMatch(/❯ Kimi K2\s+Pythinker ← current/u); + expect(out).toMatch(/❯ Kimi K2\s+Kimi ← current/u); }); it('opens the matching provider when the current canonical alias is stale', () => { @@ -135,7 +135,7 @@ describe('TabbedModelSelectorComponent', () => { component.handleInput(RIGHT); // off -> low for k2 const output = strip(component.render(120).join('\n')); - expect(component.activeTabId()).toBe('managed:pythinker-code'); + expect(component.activeTabId()).toBe('managed:kimi-code'); expect(output).toContain('Kimi K2'); component.handleInput('\r'); diff --git a/apps/pythinker-code/test/tui/components/messages/goal-markers.test.ts b/apps/pythinker-code/test/tui/components/messages/goal-markers.test.ts index f79a9bc2..98d4f3fd 100644 --- a/apps/pythinker-code/test/tui/components/messages/goal-markers.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/goal-markers.test.ts @@ -5,7 +5,7 @@ import { DynamicWorkflowModeMarkerComponent } from '#/tui/components/messages/dy import { buildGoalMarker, GoalMarkerComponent } from '#/tui/components/messages/goal-markers'; import type { GoalChange } from '@pythoughts/pythinker-code-sdk'; -const ANSI_SGR = /\[[0-9;]*m/g; +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(lines: string[]): string { return lines.join('\n').replaceAll(ANSI_SGR, ''); } diff --git a/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts b/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts index b2ff141b..4950addb 100644 --- a/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts @@ -22,7 +22,7 @@ describe('status panel report lines', () => { maxContextTokens: 10000, availableModels: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 10000, displayName: 'Kimi K2', diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts index 4a0b69d4..2134d356 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -8,7 +8,7 @@ import { import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; function strip(text: string): string { - return text.replaceAll(/\[[0-9;]*m/g, ''); + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } function call(name: string, args: Record = {}): ToolCallBlockData { diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/media.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/media.test.ts index ab4a53fd..8a13aeca 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/media.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/media.test.ts @@ -10,7 +10,7 @@ import { darkColors } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; function strip(text: string): string { - return text.replaceAll(/\[[0-9;]*m/g, ''); + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } function joinRender(components: Component[], width = 100): string { diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts index 4636f68d..70c13d35 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -9,7 +9,7 @@ import { darkColors } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; function strip(text: string): string { - return text.replaceAll(/\[[0-9;]*m/g, ''); + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } function joinRender(components: Component[], width = 100): string { diff --git a/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts index 4824636a..81bbb551 100644 --- a/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -5,7 +5,7 @@ import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; import type { GoalSnapshot } from '@pythoughts/pythinker-code-sdk'; import type { AppState } from '#/tui/types'; -const ANSI_SGR = /\[[0-9;]*m/g; +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); } diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 325b2b34..952a982e 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -232,7 +232,7 @@ function modelBlockedEvent() { function addedTranscriptText(host: ReturnType['host']): string { const component = host.state.transcriptContainer.addChild.mock.calls.at(-1)?.[0]; - return component.render(80).join('\n').replaceAll(/\[[0-9;]*m/g, ''); + return component.render(80).join('\n').replaceAll(/\u001B\[[0-9;]*m/g, ''); } function renderContainer(container: Container): string { diff --git a/apps/pythinker-code/test/tui/create-tui-state.test.ts b/apps/pythinker-code/test/tui/create-tui-state.test.ts index e006cb7e..25a8f9a1 100644 --- a/apps/pythinker-code/test/tui/create-tui-state.test.ts +++ b/apps/pythinker-code/test/tui/create-tui-state.test.ts @@ -36,6 +36,27 @@ thinkingLevel: 'off', } describe('createTUIState', () => { + it('gates rendering until the event loop starts, and ui.start() re-opens it', () => { + // The gate writes pi-tui's PRIVATE `stopped` field, and `ui.start()` clearing + // it again is what lets the TUI ever paint. If an upgrade renames the field or + // stops resetting it, the app renders nothing — a silent, total failure. This + // asserts both halves so that upgrade fails here instead of in someone's terminal. + const state = createTUIState({ + initialAppState: fakeInitialAppState(), + startup: { continueLast: false, yolo: false, auto: false, plan: false }, + layout: 'fixed', + }); + const ui = state.ui as unknown as { stopped: boolean; start: () => void }; + + expect(ui.stopped).toBe(true); + + vi.spyOn(state.terminal, 'start').mockImplementation(() => {}); + vi.spyOn(state.terminal, 'hideCursor').mockImplementation(() => {}); + ui.start(); + + expect(ui.stopped).toBe(false); + }); + it('initializes all fields with sensible defaults', () => { const opts: PythinkerTUIOptions = { initialAppState: fakeInitialAppState(), diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index ae376e08..3c219d92 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -1221,7 +1221,7 @@ command = "vim" k2: { model: 'pythoughts-v1', maxContextSize: 100, - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', }, }, })), @@ -1254,7 +1254,7 @@ command = "vim" k2: { model: 'pythoughts-v1', maxContextSize: 100, - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', }, }, })), @@ -1285,7 +1285,7 @@ command = "vim" k2: { model: 'pythoughts-v1', maxContextSize: 100, - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', }, }, })), @@ -5196,14 +5196,14 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', capabilities: ['thinking'], }, turbo: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-turbo', maxContextSize: 100, displayName: 'Kimi Turbo', @@ -5223,8 +5223,8 @@ command = "vim" }); const picker = driver.state.editorContainer.children[0]; const pickerOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); - expect(pickerOutput).toMatch(/Kimi K2\s+Pythinker ← current/); - expect(pickerOutput).toMatch(/❯ Kimi Turbo\s+Pythinker/); + expect(pickerOutput).toMatch(/Kimi K2\s+Kimi ← current/); + expect(pickerOutput).toMatch(/❯ Kimi Turbo\s+Kimi/); (picker as TabbedModelSelectorComponent).handleInput('t'); (picker as TabbedModelSelectorComponent).handleInput('u'); const filteredOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); @@ -5255,7 +5255,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5294,7 +5294,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5326,7 +5326,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5354,7 +5354,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5390,7 +5390,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5422,7 +5422,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5455,7 +5455,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5504,7 +5504,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { plain: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-plain', maxContextSize: 100, displayName: 'Plain Model', @@ -5532,7 +5532,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Old Kimi K2', @@ -5552,7 +5552,7 @@ command = "vim" tui.setAppState({ availableModels: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Fresh Kimi K2', @@ -5563,7 +5563,7 @@ command = "vim" resolve(); }; }); - return { changed: ['managed:pythinker-code'], unchanged: [], failed: [] }; + return { changed: ['managed:kimi-code'], unchanged: [], failed: [] }; }); ( tui.authFlow as unknown as { @@ -5599,7 +5599,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5722,14 +5722,14 @@ command = "vim" const { driver } = await makeDriver(); const selection = runModelSelector(driver as any, { alpha: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-alpha', maxContextSize: 100, displayName: 'Pythinker Alpha', capabilities: ['thinking'], }, turbo: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-turbo', maxContextSize: 100, displayName: 'Kimi Turbo', diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index dcf60215..b710340b 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -42,6 +42,10 @@ import { } from '#/tui/utils/terminal-theme'; import { LEGACY_TEST_PATHS, PARITY_CASES } from './parity/feature-matrix'; +/** The picker colours labels and values separately, so raw frames interleave SGR escapes. */ +const ANSI_SGR = /\u001B\[[0-9;]*m/g; +const stripAnsi = (frame: string): string => frame.replaceAll(ANSI_SGR, ''); + vi.mock('#/tui/commands/prompts', async (importOriginal) => { const actual = await importOriginal(); return { @@ -296,7 +300,7 @@ function createResumeState(overrides: { permissionMode?: string; planMode?: bool } function loginRequiredError(): Error & { readonly code: string } { - return Object.assign(new Error('OAuth provider "managed:pythinker-code" requires login.'), { + return Object.assign(new Error('OAuth provider "managed:kimi-code" requires login.'), { code: 'auth.login_required', }); } @@ -905,13 +909,13 @@ describe('PythinkerTUI startup', () => { it('passes the CLI model override when creating a fresh startup session', async () => { const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({ model: 'pythinker-code/k2.5' })); + const driver = makeDriver(harness, makeStartupInput({ model: 'kimi-code/k2.5' })); await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).toHaveBeenCalledWith({ workDir: '/tmp/proj-a', - model: 'pythinker-code/k2.5', + model: 'kimi-code/k2.5', permission: undefined, planMode: undefined, }); @@ -938,13 +942,13 @@ describe('PythinkerTUI startup', () => { }); const driver = makeDriver( harness, - makeStartupInput({ continue: true, model: 'pythinker-code/k2.5' }), + makeStartupInput({ continue: true, model: 'kimi-code/k2.5' }), ); await expect(driver.init()).resolves.toBe(true); - expect(session.setModel).toHaveBeenCalledWith('pythinker-code/k2.5'); - expect(driver.state.appState.model).toBe('pythinker-code/k2.5'); + expect(session.setModel).toHaveBeenCalledWith('kimi-code/k2.5'); + expect(driver.state.appState.model).toBe('kimi-code/k2.5'); }); it('enters picker startup for bare --session without creating a session', async () => { @@ -1178,7 +1182,7 @@ describe('PythinkerTUI startup', () => { firstPicker.handleInput('c'); firstPicker.handleInput('w'); firstPicker.handleInput('d'); - expect(firstPicker.render(160).join('\n')).toContain('Search: cwd'); + expect(stripAnsi(firstPicker.render(160).join('\n'))).toContain('Search: cwd'); firstPicker.handleInput('\u0001'); await new Promise((resolve) => setImmediate(resolve)); @@ -1187,7 +1191,7 @@ describe('PythinkerTUI startup', () => { handleInput(data: string): void; render(width: number): string[]; }; - const output = allPicker.render(160).join('\n'); + const output = stripAnsi(allPicker.render(160).join('\n')); expect(driver.state.sessionsScope).toBe('all'); expect(output).toContain('All sessions'); @@ -1517,7 +1521,7 @@ describe('PythinkerTUI startup', () => { }); vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'pythinker-code', + platformId: 'kimi-code', catalog: {}, }); await handleLoginCommand(driver as any); @@ -1572,7 +1576,7 @@ describe('PythinkerTUI startup', () => { await expect(driver.init()).resolves.toBe(false); vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'pythinker-code', + platformId: 'kimi-code', catalog: {}, }); await handleLoginCommand(driver as any); @@ -1606,7 +1610,7 @@ describe('PythinkerTUI startup', () => { expect(driver.state.appState.thinkingLevel).toBe('off'); vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'pythinker-code', + platformId: 'kimi-code', catalog: {}, }); await handleLoginCommand(driver as any); @@ -1619,7 +1623,7 @@ describe('PythinkerTUI startup', () => { maxContextTokens: 100, }); expect(harness.track).toHaveBeenCalledWith('login', { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', already_logged_in: false, }); }); @@ -1629,7 +1633,7 @@ describe('PythinkerTUI startup', () => { const harness = makeHarness(session, { auth: { status: vi.fn(async () => ({ - providers: [{ providerName: 'managed:pythinker-code', hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -1642,20 +1646,20 @@ describe('PythinkerTUI startup', () => { harness.track.mockClear(); vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'pythinker-code', + platformId: 'kimi-code', catalog: {}, }); await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( - 'managed:pythinker-code', + 'managed:kimi-code', expect.objectContaining({ signal: expect.any(AbortSignal), onDeviceCode: expect.any(Function), }), ); expect(harness.track).toHaveBeenCalledWith('login', { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', already_logged_in: true, }); }); @@ -1680,13 +1684,13 @@ describe('PythinkerTUI startup', () => { await expect(driver.init()).resolves.toBe(false); vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'pythinker-code', + platformId: 'kimi-code', catalog: {}, }); await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( - 'managed:pythinker-code', + 'managed:kimi-code', expect.objectContaining({ signal: expect.any(AbortSignal), onDeviceCode: expect.any(Function), @@ -1695,7 +1699,7 @@ describe('PythinkerTUI startup', () => { expect(warn).toHaveBeenCalledWith( 'login failed', expect.objectContaining({ - providerName: 'managed:pythinker-code', + providerName: 'managed:kimi-code', alreadyLoggedIn: false, sessionId: 'ses-1', error: expect.objectContaining({ @@ -1880,13 +1884,13 @@ describe('PythinkerTUI startup', () => { const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: 'managed:pythinker-code', model: 'pythoughts-v1', maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'pythoughts-v1', maxContextSize: 100 }, }, - providers: { 'managed:pythinker-code': { type: 'pythinker' } }, + providers: { 'managed:kimi-code': { type: 'pythinker' } }, })), auth: { status: vi.fn(async () => ({ - providers: [{ providerName: 'managed:pythinker-code', hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -1898,17 +1902,17 @@ describe('PythinkerTUI startup', () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:pythinker-code'); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); await handleLogoutCommand(driver as any); - expect(harness.auth.logout).toHaveBeenCalledWith('managed:pythinker-code'); + expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); expect(session.close).toHaveBeenCalledOnce(); expect(driver.state.appState).toMatchObject({ sessionId: '', model: '', sessionTitle: null, }); - expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'managed:pythinker-code' }); + expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'managed:kimi-code' }); }); it('keeps the active session when logging out a different provider', async () => { @@ -1917,17 +1921,17 @@ describe('PythinkerTUI startup', () => { const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: 'managed:pythinker-code', model: 'pythoughts-v1', maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'pythoughts-v1', maxContextSize: 100 }, }, providers: { - 'managed:pythinker-code': { type: 'pythinker' }, + 'managed:kimi-code': { type: 'pythinker' }, openai: { type: 'openai', baseUrl: 'https://api.openai.com/v1' }, }, })), removeProvider, auth: { status: vi.fn(async () => ({ - providers: [{ providerName: 'managed:pythinker-code', hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -1957,15 +1961,15 @@ describe('PythinkerTUI startup', () => { const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: 'managed:pythinker-code', model: 'pythoughts-v1', maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'pythoughts-v1', maxContextSize: 100 }, }, - providers: { 'managed:pythinker-code': { type: 'pythinker' } }, + providers: { 'managed:kimi-code': { type: 'pythinker' } }, })), auth: { // Token gone (e.g. credentials file deleted) but the managed entry // is still sitting in config.providers. status: vi.fn(async () => ({ - providers: [{ providerName: 'managed:pythinker-code', hasToken: false }], + providers: [{ providerName: 'managed:kimi-code', hasToken: false }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -1976,10 +1980,10 @@ describe('PythinkerTUI startup', () => { await expect(driver.init()).resolves.toBe(false); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:pythinker-code'); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); await handleLogoutCommand(driver as any); - expect(harness.auth.logout).toHaveBeenCalledWith('managed:pythinker-code'); + expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); }); it('starts TUI without replaying when --continue needs OAuth login', async () => { diff --git a/apps/pythinker-code/test/tui/task-output-viewer.test.ts b/apps/pythinker-code/test/tui/task-output-viewer.test.ts index 1a07c9d4..d95c275f 100644 --- a/apps/pythinker-code/test/tui/task-output-viewer.test.ts +++ b/apps/pythinker-code/test/tui/task-output-viewer.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import { TaskOutputViewer } from '@/tui/components/dialogs/task-output-viewer'; import { darkColors } from '@/tui/theme/colors'; -const ANSI_SGR = /\[[0-9;]*m/g; +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); } diff --git a/apps/pythinker-code/test/tui/tasks-browser.test.ts b/apps/pythinker-code/test/tui/tasks-browser.test.ts index 86f1af9c..01d07545 100644 --- a/apps/pythinker-code/test/tui/tasks-browser.test.ts +++ b/apps/pythinker-code/test/tui/tasks-browser.test.ts @@ -10,7 +10,7 @@ import { import { darkColors } from '@/tui/theme/colors'; import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; -const ANSI_SGR = /\[[0-9;]*m/g; +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); } diff --git a/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts b/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts index b700dafe..d57c7ef1 100644 --- a/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts +++ b/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { - PYTHINKER_CODE_PROVIDER_NAME, - resolvePythinkerCodeOAuthKey, - resolvePythinkerCodeOAuthRef, + KIMI_CODE_PROVIDER_NAME, + resolveKimiCodeOAuthKey, + resolveKimiCodeOAuthRef, } from '@pythoughts/pythinker-code-oauth'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -73,14 +73,14 @@ describe('refreshAllProviderModels', () => { const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; const envBaseUrl = 'https://api.env.example.test/coding/v1'; const envOauthHost = 'https://auth.env.example.test'; - const configuredOauthKey = resolvePythinkerCodeOAuthKey({ baseUrl: configuredBaseUrl }); - const envOauthRef = resolvePythinkerCodeOAuthRef({ + const configuredOauthKey = resolveKimiCodeOAuthKey({ baseUrl: configuredBaseUrl }); + const envOauthRef = resolveKimiCodeOAuthRef({ oauthHost: envOauthHost, baseUrl: envBaseUrl, }); const config: PythinkerConfig = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', baseUrl: configuredBaseUrl, apiKey: '', @@ -92,14 +92,14 @@ describe('refreshAllProviderModels', () => { }, }, models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['thinking', 'tool_use'], }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', telemetry: true, }; vi.stubEnv('PYTHINKER_CODE_BASE_URL', envBaseUrl); @@ -135,9 +135,9 @@ describe('refreshAllProviderModels', () => { }); expect(result.failed).toEqual([]); - expect(result.unchanged).toEqual([PYTHINKER_CODE_PROVIDER_NAME]); + expect(result.unchanged).toEqual([KIMI_CODE_PROVIDER_NAME]); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(resolveOAuthToken).toHaveBeenCalledWith(PYTHINKER_CODE_PROVIDER_NAME, envOauthRef); + expect(resolveOAuthToken).toHaveBeenCalledWith(KIMI_CODE_PROVIDER_NAME, envOauthRef); }); it('can refresh only the managed OAuth provider without fetching third-party registries', async () => { @@ -145,13 +145,13 @@ describe('refreshAllProviderModels', () => { const registryUrl = 'https://registry.example.test/v1/models/api.json'; const config: PythinkerConfig = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', baseUrl, apiKey: '', oauth: { storage: 'file', - key: resolvePythinkerCodeOAuthKey({ baseUrl }), + key: resolveKimiCodeOAuthKey({ baseUrl }), }, }, custom: { @@ -162,8 +162,8 @@ describe('refreshAllProviderModels', () => { }, }, models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['thinking', 'tool_use'], @@ -177,7 +177,7 @@ describe('refreshAllProviderModels', () => { displayName: 'Custom M1', }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', telemetry: true, }; const host = makeRefreshHost(config); @@ -215,7 +215,7 @@ describe('refreshAllProviderModels', () => { expect(result.failed).toEqual([]); expect(result.changed).toEqual([ { - providerId: PYTHINKER_CODE_PROVIDER_NAME, + providerId: KIMI_CODE_PROVIDER_NAME, providerName: 'Pythinker', added: 0, removed: 0, @@ -223,7 +223,7 @@ describe('refreshAllProviderModels', () => { ]); expect(result.unchanged).toEqual([]); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(host.current().models?.['pythinker-code/pythinker-for-coding']?.displayName).toBe('Fresh Pythinker'); + expect(host.current().models?.['kimi-code/pythinker-for-coding']?.displayName).toBe('Fresh Pythinker'); expect(host.current().models?.['custom/m1']?.displayName).toBe('Custom M1'); }); @@ -1111,21 +1111,21 @@ max_context_size = 64000 it('forces default thinking on when the refreshed default model cannot disable thinking', async () => { const host = makeRefreshHost({ providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { - 'pythinker-code/pythinker-deep-coder': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-deep-coder': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-deep-coder', maxContextSize: 262144, capabilities: ['thinking', 'tool_use'], }, }, - defaultModel: 'pythinker-code/pythinker-deep-coder', + defaultModel: 'kimi-code/pythinker-deep-coder', defaultThinking: false, telemetry: true, } as unknown as PythinkerConfig); @@ -1157,12 +1157,12 @@ max_context_size = 64000 }); expect(result.failed).toEqual([]); - expect(host.current().models?.['pythinker-code/pythinker-deep-coder']?.capabilities).toEqual([ + expect(host.current().models?.['kimi-code/pythinker-deep-coder']?.capabilities).toEqual([ 'thinking', 'always_thinking', 'tool_use', ]); - expect(host.current().defaultModel).toBe('pythinker-code/pythinker-deep-coder'); + expect(host.current().defaultModel).toBe('kimi-code/pythinker-deep-coder'); expect(host.current().defaultThinking).toBe(true); }); }); diff --git a/apps/pythinker-code/test/tui/utils/thinking-levels.test.ts b/apps/pythinker-code/test/tui/utils/thinking-levels.test.ts index 9ddc4de3..18fbbb1e 100644 --- a/apps/pythinker-code/test/tui/utils/thinking-levels.test.ts +++ b/apps/pythinker-code/test/tui/utils/thinking-levels.test.ts @@ -10,7 +10,7 @@ import { function model(overrides: Partial = {}): ModelAlias { return { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'k2', maxContextSize: 200_000, capabilities: ['thinking'], diff --git a/apps/pythinker-web/src/components/SettingsDialog.vue b/apps/pythinker-web/src/components/SettingsDialog.vue index 95066c6c..c0b35027 100644 --- a/apps/pythinker-web/src/components/SettingsDialog.vue +++ b/apps/pythinker-web/src/components/SettingsDialog.vue @@ -264,7 +264,7 @@ function setTab(tab: SettingsTab): void {

{{ t('settings.account') }}

- {{ authReady ? 'managed:pythinker-code' : t('sidebar.notSignedIn') }} + {{ authReady ? 'managed:kimi-code' : t('sidebar.notSignedIn') }} {{ accountModel }}
diff --git a/apps/site/src/App.vue b/apps/site/src/App.vue index ec267581..a0b42f02 100644 --- a/apps/site/src/App.vue +++ b/apps/site/src/App.vue @@ -227,6 +227,20 @@ onUnmounted(() => {

Pythinker Code

Think first, then code.

An open-source AI engineering agent for your terminal. It reads your repo, edits files, runs commands, and iterates until the job is done.

+ + npm downloads per month for @pythoughts/pythinker-code +
@@ -630,7 +644,7 @@ onUnmounted(() => { .hero-install { max-width: 720px; - margin: 36px auto 0; + margin: 10px auto 0; } .hero-download-milestone { @@ -639,6 +653,25 @@ onUnmounted(() => { justify-content: center; } +.hero-npm-badge { + display: inline-flex; + margin-top: 14px; + border-radius: var(--radius); + opacity: 0.85; + transition: opacity 0.2s ease; +} + +.hero-npm-badge:hover { + opacity: 1; +} + +/* ponytail: width:auto lets the badge grow as the download count does; the width attr only reserves space */ +.hero-npm-badge img { + display: block; + width: auto; + height: 20px; +} + .hero-caption { margin-top: 12px; color: var(--ink-subtle); diff --git a/apps/site/src/components/LegacyDownloadsPopup.vue b/apps/site/src/components/LegacyDownloadsPopup.vue index 3e560d1a..a1740150 100644 --- a/apps/site/src/components/LegacyDownloadsPopup.vue +++ b/apps/site/src/components/LegacyDownloadsPopup.vue @@ -25,7 +25,7 @@ const visible = ref(true); aria-label="View pythinker-code download statistics on Pepy" > PyPI download count for pythinker-code diff --git a/apps/vscode/package.json b/apps/vscode/package.json index f3ba029b..f8fb4180 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -3,7 +3,7 @@ "publisher": "pythoughts", "displayName": "Pythinker Code", "description": "Pythinker Code extension for VS Code", - "version": "0.6.7", + "version": "0.8.3", "private": true, "license": "Apache-2.0", "type": "module", @@ -53,7 +53,7 @@ "pythinker.yoloMode": { "type": "boolean", "default": false, - "description": "Auto-approve regular tool calls; the agent may still ask questions" + "description": "Start new sessions in YOLO mode (auto-approve regular tool calls; the agent may still ask questions). Sessions keep the mode they were last left in; changing this applies to the open sessions too." }, "pythinker.autosave": { "type": "boolean", @@ -249,6 +249,7 @@ "package:platform": "node scripts/vsix-package.mjs", "package:verify": "node scripts/vsix-verify.mjs", "publish:vsix": "node scripts/vsix-publish.mjs", + "release": "node scripts/release-extension.mjs", "publish:ovsx": "node scripts/ovsx-publish.mjs" }, "devDependencies": { diff --git a/apps/vscode/resources/pythinker-icon-from-ico.png b/apps/vscode/resources/pythinker-icon-from-ico.png deleted file mode 100644 index 865d7529..00000000 Binary files a/apps/vscode/resources/pythinker-icon-from-ico.png and /dev/null differ diff --git a/apps/vscode/resources/pythinker-icon-storefront.png b/apps/vscode/resources/pythinker-icon-storefront.png index 865d7529..a604484e 100644 Binary files a/apps/vscode/resources/pythinker-icon-storefront.png and b/apps/vscode/resources/pythinker-icon-storefront.png differ diff --git a/apps/vscode/scripts/ovsx-publish.mjs b/apps/vscode/scripts/ovsx-publish.mjs index 05809c6b..fcf476da 100644 --- a/apps/vscode/scripts/ovsx-publish.mjs +++ b/apps/vscode/scripts/ovsx-publish.mjs @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'; import { runLocalCli } from './local-cli.mjs'; import { parsePublishArguments, publishUsage } from './publish-args.mjs'; +import { messageOf, publishEachTarget } from './publish-retry.mjs'; import { extensionRoot, isMainModule } from './vsix-targets.mjs'; import { verifyVsix } from './vsix-verify.mjs'; @@ -15,22 +16,24 @@ async function main() { if (!process.env.OVSX_PAT) throw new Error('OVSX_PAT is required to publish.'); await verifyInputs(options); - for (const file of options.files) { - console.log(`Publishing verified package ${file}...`); - try { - runLocalCli('ovsx', 'ovsx', ['publish', file], { - cwd: extensionRoot, - encoding: 'utf8', - stdio: 'pipe', - }); - } catch (error) { - if (/already exists/i.test(error instanceof Error ? error.message : String(error))) { - console.log(`Package already exists: ${file}`); - continue; + await publishEachTarget({ + targets: options.targets, + files: options.files, + registry: 'Open VSX', + publishOne: (file) => { + try { + runLocalCli('ovsx', 'ovsx', ['publish', file], { + cwd: extensionRoot, + encoding: 'utf8', + stdio: 'pipe', + }); + return 'published'; + } catch (error) { + if (/already exists/i.test(messageOf(error))) return 'skipped'; + throw error; } - throw error; - } - } + }, + }); } async function verifyInputs(options) { diff --git a/apps/vscode/scripts/publish-retry.mjs b/apps/vscode/scripts/publish-retry.mjs new file mode 100644 index 00000000..944d9af1 --- /dev/null +++ b/apps/vscode/scripts/publish-retry.mjs @@ -0,0 +1,106 @@ +// Publishing to a registry fails in three different ways, and they need three +// different responses: a flaky network call should be retried, a bad token +// should stop everything immediately, and a rejected package should fail only +// its own target so the remaining ones still ship. +const TRANSIENT_PATTERN = + /request timeout|etimedout|econnreset|econnrefused|enotfound|eai_again|socket hang up|network|\b(?:429|500|502|503|504)\b|too many requests|service unavailable|gateway/i; +const AUTH_PATTERN = /\b401\b|unauthorized|invalidaccess|access denied|not allowed|forbidden|\b403\b|authentication|invalid token|expired/i; + +export const DEFAULT_ATTEMPTS = 3; + +export function messageOf(error) { + return error instanceof Error ? error.message : String(error); +} + +/** + * `auth` aborts the whole run — every remaining target would fail identically. + * `transient` is worth retrying. `fatal` fails one target and lets the rest go. + */ +export function classifyError(error) { + const message = messageOf(error); + if (AUTH_PATTERN.test(message)) return 'auth'; + if (TRANSIENT_PATTERN.test(message)) return 'transient'; + return 'fatal'; +} + +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Runs `action`, retrying only transient failures with a widening backoff. + * Returns the action's value; rethrows the last error once attempts run out. + */ +export async function withRetry(action, options = {}) { + const attempts = options.attempts ?? DEFAULT_ATTEMPTS; + const label = options.label ?? 'operation'; + const backoffMs = options.backoffMs ?? [5000, 15000]; + + for (let attempt = 1; ; attempt += 1) { + try { + return await action(); + } catch (error) { + const kind = classifyError(error); + if (kind !== 'transient' || attempt >= attempts) { + throw error; + } + const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)]; + console.warn(`${label}: ${kind} failure on attempt ${attempt}/${attempts}, retrying in ${wait / 1000}s...`); + console.warn(` ${messageOf(error).split('\n')[0]}`); + await delay(wait); + } + } +} + +/** + * Publishes every target, keeping going after a per-target failure so one flaky + * upload cannot strand the rest. Throws a summary naming exactly which targets + * are live and which still need a re-run, because a half-published version is + * the state that is hardest to reason about afterwards. + */ +export async function publishEachTarget({ targets, files, registry, publishOne }) { + const published = []; + const skipped = []; + const failures = []; + let abortReason = ''; + + for (let index = 0; index < targets.length; index += 1) { + const target = targets[index]; + const file = files[index]; + if (abortReason) { + failures.push({ target, message: `not attempted (${abortReason})` }); + continue; + } + console.log(`Publishing verified package ${file}...`); + try { + const outcome = await withRetry(() => publishOne(file, target), { label: `${registry} ${target}` }); + (outcome === 'skipped' ? skipped : published).push(target); + } catch (error) { + const kind = classifyError(error); + failures.push({ target, message: messageOf(error).split('\n')[0] }); + if (kind === 'auth') { + abortReason = 'aborted after an authentication failure'; + } + } + } + + summarize({ registry, published, skipped, failures }); + if (failures.length > 0) { + throw new Error( + `${registry}: ${failures.length} of ${targets.length} target(s) failed. ` + + `Published targets are live and will be skipped on a re-run — fix the cause and run the publish command again.`, + ); + } + return { published, skipped }; +} + +function summarize({ registry, published, skipped, failures }) { + console.log(`\n${registry} summary:`); + if (published.length > 0) console.log(` published: ${published.join(', ')}`); + if (skipped.length > 0) console.log(` already published: ${skipped.join(', ')}`); + for (const failure of failures) { + console.log(` FAILED ${failure.target}: ${failure.message}`); + } +} diff --git a/apps/vscode/scripts/release-extension.mjs b/apps/vscode/scripts/release-extension.mjs new file mode 100644 index 00000000..dfe04263 --- /dev/null +++ b/apps/vscode/scripts/release-extension.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +// Tag and publish the VS Code extension in one pass: +// preflight -> bump -> commit -> build -> package+verify -> publish -> tag -> push. +// +// pnpm --filter pythinker-code run release 0.8.3 +// pnpm --filter pythinker-code run release 0.8.3 --dry-run +// +// The Marketplace token is read from the macOS keychain, so it never reaches a +// shell history or a file. Store it once with: +// security add-generic-password -s pythinker-vsce-pat -a "$USER" -w +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { extensionRoot, isMainModule } from './vsix-targets.mjs'; + +const KEYCHAIN_SERVICE = 'pythinker-vsce-pat'; +const OVSX_KEYCHAIN_SERVICE = 'pythinker-ovsx-pat'; +const MANIFEST = join(extensionRoot, 'package.json'); +const repoRoot = join(extensionRoot, '..', '..'); + +function run(command, args, options = {}) { + return execFileSync(command, args, { stdio: 'inherit', cwd: repoRoot, ...options }); +} + +function capture(command, args, options = {}) { + return execFileSync(command, args, { encoding: 'utf8', cwd: repoRoot, ...options }).trim(); +} + +/** A secret in argv or the environment leaks into `ps` and shell history; the keychain does not. */ +function keychainSecret(service) { + try { + return capture('security', ['find-generic-password', '-s', service, '-w'], { stdio: ['ignore', 'pipe', 'ignore'] }); + } catch { + return ''; + } +} + +function parseArguments(argv) { + let version = ''; + let dryRun = false; + for (const argument of argv) { + if (argument === '--dry-run') dryRun = true; + else if (argument === '--') continue; + else if (argument.startsWith('-')) throw new Error(`Unknown option: ${argument}`); + else if (version) throw new Error('Pass exactly one version.'); + else version = argument; + } + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error('Usage: release [--dry-run]'); + } + return { version, dryRun }; +} + +function readManifest() { + return JSON.parse(readFileSync(MANIFEST, 'utf8')); +} + +function isNewer(next, current) { + const a = next.split('.').map(Number); + const b = current.split('.').map(Number); + for (let i = 0; i < 3; i += 1) { + if (a[i] !== b[i]) return a[i] > b[i]; + } + return false; +} + +/** The Marketplace refuses a duplicate version, so catch it before anything is built. */ +async function assertUnpublished(version) { + const response = await fetch('https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json;api-version=7.1-preview.1' }, + body: JSON.stringify({ filters: [{ criteria: [{ filterType: 7, value: 'pythoughts.pythinker-code' }] }], flags: 914 }), + }); + if (!response.ok) { + console.warn(`Could not reach the Marketplace to check for ${version}; continuing.`); + return; + } + const body = await response.json(); + const versions = body.results?.[0]?.extensions?.[0]?.versions ?? []; + if (versions.some((entry) => entry.version === version)) { + throw new Error(`${version} is already on the Marketplace. Versions cannot be reused — pick the next one.`); + } +} + +function assertCleanTree() { + if (capture('git', ['status', '--porcelain'])) { + throw new Error('Working tree is dirty. Commit or stash first so the tag matches what ships.'); + } +} + +async function main() { + const { version, dryRun } = parseArguments(process.argv.slice(2)); + const tag = `pythinker-code-vscode@${version}`; + const manifest = readManifest(); + + assertCleanTree(); + if (!isNewer(version, manifest.version)) { + throw new Error(`${version} does not come after the current ${manifest.version}.`); + } + if (capture('git', ['tag', '--list', tag])) { + throw new Error(`Tag ${tag} already exists.`); + } + await assertUnpublished(version); + + const vscePat = process.env.VSCE_PAT || keychainSecret(KEYCHAIN_SERVICE); + if (!vscePat && !dryRun) { + throw new Error( + `No Marketplace token. Store one with: security add-generic-password -s ${KEYCHAIN_SERVICE} -a "$USER" -w`, + ); + } + + console.log(`\n== ${manifest.version} -> ${version}${dryRun ? ' (dry run)' : ''} ==\n`); + const originalManifest = readFileSync(MANIFEST, 'utf8'); + writeFileSync(MANIFEST, originalManifest.replace(`"version": "${manifest.version}"`, `"version": "${version}"`)); + + if (dryRun) { + // A dry run must leave the tree exactly as it found it, including after a + // failed build — otherwise the next real run trips its own clean-tree check. + try { + run('pnpm', ['build']); + run('pnpm', ['--filter', 'pythinker-code', 'run', 'package:platform']); + console.log(`\nDry run: built and verified ${version}. Nothing published, nothing tagged.`); + } finally { + writeFileSync(MANIFEST, originalManifest); + } + return; + } + + run('pnpm', ['build']); + run('pnpm', ['--filter', 'pythinker-code', 'run', 'package:platform']); + + // Commit before publishing so the shipped bits always correspond to a commit, + // and tag only once the Marketplace has actually accepted them. + run('git', ['add', 'apps/vscode/package.json']); + run('git', ['commit', '-m', `chore(vscode): release ${version}`]); + + try { + run('pnpm', ['--filter', 'pythinker-code', 'run', 'publish:vsix'], { env: { ...process.env, VSCE_PAT: vscePat } }); + } catch (error) { + // The version bump is already committed and some targets may already be + // live, so say exactly how to finish rather than leaving it to be worked out. + throw new Error( + `${error instanceof Error ? error.message : String(error)}\n\n` + + `${version} is partly published and NOT tagged. The publish summary above lists which\n` + + `targets are live; published ones are skipped on a re-run. Finish with:\n` + + ` pnpm --filter pythinker-code run publish:vsix\n` + + ` git tag -a ${tag} -m "Pythinker Code VS Code extension ${version}"\n` + + `Do not bump the version again — ${version} is already consumed.`, + ); + } + + const ovsxPat = process.env.OVSX_PAT || keychainSecret(OVSX_KEYCHAIN_SERVICE); + if (ovsxPat) { + // Open VSX serves Cursor / VSCodium / Windsurf, but it must never undo a + // successful Marketplace publish. + try { + run('pnpm', ['--filter', 'pythinker-code', 'run', 'publish:ovsx'], { env: { ...process.env, OVSX_PAT: ovsxPat } }); + } catch (error) { + console.warn(`Open VSX publish failed, Marketplace is live: ${error instanceof Error ? error.message : String(error)}`); + } + } else { + console.warn(`No Open VSX token (keychain service ${OVSX_KEYCHAIN_SERVICE}) — skipped.`); + } + + run('git', ['tag', '-a', tag, '-m', `Pythinker Code VS Code extension ${version}`]); + console.log(`\nPublished ${version} and tagged ${tag}.`); + console.log(`Push it with: git push origin ${capture('git', ['rev-parse', '--abbrev-ref', 'HEAD'])} ${tag}`); +} + +if (isMainModule(import.meta.url)) { + main().catch((error) => { + console.error(`\nRelease failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/apps/vscode/scripts/vsix-publish.mjs b/apps/vscode/scripts/vsix-publish.mjs index e7952478..9f51453f 100644 --- a/apps/vscode/scripts/vsix-publish.mjs +++ b/apps/vscode/scripts/vsix-publish.mjs @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'; import { runLocalCli } from './local-cli.mjs'; import { parsePublishArguments, publishUsage } from './publish-args.mjs'; +import { publishEachTarget } from './publish-retry.mjs'; import { extensionRoot, isMainModule } from './vsix-targets.mjs'; import { verifyVsix } from './vsix-verify.mjs'; @@ -12,18 +13,30 @@ async function main() { console.log(publishUsage('Visual Studio Marketplace')); return; } - if (!process.env.VSCE_PAT) throw new Error('VSCE_PAT is required to publish.'); + // A token is the only option in CI, but a maintainer publishing by hand can + // authenticate as the Entra identity `az login` already established instead. + const azureCredential = process.env.VSCE_AZURE_CREDENTIAL === '1'; + if (!azureCredential && !process.env.VSCE_PAT) { + throw new Error('Set VSCE_PAT, or VSCE_AZURE_CREDENTIAL=1 to publish as the signed-in Entra identity.'); + } await verifyInputs(options); - for (const file of options.files) { - console.log(`Publishing verified package ${file}...`); - runLocalCli( - '@vscode/vsce', - 'vsce', - ['publish', '--packagePath', file, '--skip-duplicate'], - { cwd: extensionRoot }, - ); - } + await publishEachTarget({ + targets: options.targets, + files: options.files, + registry: 'Marketplace', + publishOne: (file) => { + const result = runLocalCli( + '@vscode/vsce', + 'vsce', + ['publish', '--packagePath', file, '--skip-duplicate', ...(azureCredential ? ['--azure-credential'] : [])], + { cwd: extensionRoot, encoding: 'utf8', stdio: 'pipe' }, + ); + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + process.stdout.write(output); + return /already published/i.test(output) ? 'skipped' : 'published'; + }, + }); } async function verifyInputs(options) { diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts index 3eafc5b1..a8116ae7 100644 --- a/apps/vscode/shared/bridge.ts +++ b/apps/vscode/shared/bridge.ts @@ -25,6 +25,11 @@ export const Methods = { OpenFolder: "openFolder", GetModels: "getModels", + GetProviders: "getProviders", + GetProviderCatalog: "getProviderCatalog", + AddCatalogProvider: "addCatalogProvider", + RemoveProvider: "removeProvider", + GetMCPServers: "getMCPServers", AddMCPServer: "addMCPServer", UpdateMCPServer: "updateMCPServer", @@ -90,6 +95,8 @@ export type RpcMessageValidation = export const Events = { ExtensionConfigChanged: "extensionConfigChanged", MCPServersChanged: "mcpServersChanged", + ProvidersChanged: "providersChanged", + SlashCommandsChanged: "slashCommandsChanged", StreamEvent: "streamEvent", FocusInput: "focusInput", InsertMention: "insertMention", @@ -139,6 +146,8 @@ function validateParams(method: RpcMethod, params: unknown): boolean { case Methods.OpenSettings: case Methods.OpenFolder: case Methods.GetModels: + case Methods.GetProviders: + case Methods.GetProviderCatalog: case Methods.GetMCPServers: case Methods.AbortChat: case Methods.ResetSession: @@ -167,6 +176,14 @@ function validateParams(method: RpcMethod, params: unknown): boolean { && isOptionalType(params["enableNewConversationShortcut"], "boolean") && isOptionalType(params["showThinkingContent"], "boolean") && isOptionalType(params["showThinkingExpanded"], "boolean"); + case Methods.AddCatalogProvider: + return isPlainObject(params) + && isNonEmptyString(params["providerId"]) + && isOptionalType(params["apiKey"], "string") + && isOptionalType(params["apiKeyEnvVar"], "string") + && isOptionalType(params["defaultModel"], "string"); + case Methods.RemoveProvider: + return hasNonEmptyString(params, "providerId"); case Methods.AddMCPServer: return isMcpServerConfig(params); case Methods.UpdateMCPServer: diff --git a/apps/vscode/shared/legacy-sdk.ts b/apps/vscode/shared/legacy-sdk.ts index d006c209..49e4b6e5 100644 --- a/apps/vscode/shared/legacy-sdk.ts +++ b/apps/vscode/shared/legacy-sdk.ts @@ -126,6 +126,22 @@ export interface QuestionResponse { export interface SubagentEvent { parent_tool_call_id: string; event: LegacyWireEvent; + /** Emitting subagent. */ + agent_id: string; + /** Display name for the lane, e.g. "explore". Absent for non-DynamicWorkflow subagents. */ + agent_label?: string; + /** Position within the DynamicWorkflow batch, 1-based. Absent when the subagent has none. */ + agent_index?: number; +} + +export interface SubagentStatusPayload { + parent_tool_call_id: string; + agent_id: string; + agent_label?: string; + agent_index?: number; + status: 'spawned' | 'running' | 'done' | 'failed' | 'suspended'; + error?: string; + result_summary?: string; } export type LegacyWireEvent = @@ -142,6 +158,7 @@ export type LegacyWireEvent = | { type: 'ToolResult'; payload: ToolResult } | { type: 'SteerInput'; payload: { user_input: string | ContentPart[] } } | { type: 'SubagentEvent'; payload: SubagentEvent } + | { type: 'SubagentStatus'; payload: SubagentStatusPayload } | { type: string; payload: unknown }; export type StreamEvent = diff --git a/apps/vscode/shared/types.ts b/apps/vscode/shared/types.ts index 1b2c5a3c..c9b86a30 100644 --- a/apps/vscode/shared/types.ts +++ b/apps/vscode/shared/types.ts @@ -69,3 +69,43 @@ export interface LoginStatus { } export type { QuestionRequest, QuestionItem, QuestionOption, QuestionResponse } from "./legacy-sdk"; + +/** A provider as it exists in config.toml. The API key itself never crosses the bridge. */ +export interface ConfiguredProvider { + id: string; + type: string; + baseUrl?: string; + keySource: "config" | "env" | "oauth" | "none"; + /** Host of the provider's base URL, shown so a managed provider is identifiable. */ + host?: string; + apiKeyEnvVar?: string; + catalogUrl?: string; + models: string[]; +} + +export interface ProvidersView { + providers: ConfiguredProvider[]; + defaultModel: string | null; +} + +export interface CatalogModelSummary { + id: string; + name: string; + maxContextTokens?: number; + thinking: boolean; +} + +export interface CatalogProviderSummary { + id: string; + name: string; + wire?: string; + apiKeyEnvVar?: string; + models: CatalogModelSummary[]; +} + +export interface AddCatalogProviderRequest { + providerId: string; + apiKey?: string; + apiKeyEnvVar?: string; + defaultModel?: string; +} diff --git a/apps/vscode/src/PythinkerWebviewProvider.ts b/apps/vscode/src/PythinkerWebviewProvider.ts index d280333c..94090bf5 100644 --- a/apps/vscode/src/PythinkerWebviewProvider.ts +++ b/apps/vscode/src/PythinkerWebviewProvider.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import type { PythinkerHarness } from "@pythoughts/pythinker-code-sdk"; +import type { PermissionMode, PythinkerHarness } from "@pythoughts/pythinker-code-sdk"; import { Events } from "../shared/bridge"; import { BridgeHandler } from "./bridge-handler"; @@ -145,8 +145,8 @@ export class PythinkerWebviewProvider implements vscode.WebviewViewProvider { return this.bridgeHandler.getBaselineContent(sessionId, filePath); } - async setYoloModeForActiveSessions(enabled: boolean): Promise { - await this.bridgeHandler.runtime.setYoloModeForActiveSessions(enabled); + async setPermissionModeForActiveSessions(mode: PermissionMode): Promise { + await this.bridgeHandler.runtime.setPermissionModeForActiveSessions(mode); } private getHtml(webviewId: string, webview: vscode.Webview): string { diff --git a/apps/vscode/src/bridge-handler.ts b/apps/vscode/src/bridge-handler.ts index 8799bf06..7033981d 100644 --- a/apps/vscode/src/bridge-handler.ts +++ b/apps/vscode/src/bridge-handler.ts @@ -2,11 +2,13 @@ import * as path from "node:path"; import * as vscode from "vscode"; import { + Events, validateRpcMessage, type RpcMethod, type RpcResult, } from "../shared/bridge"; import { VSCodeSettings } from "./config/vscode-settings"; +import { getSlashCommands } from "./handlers/config.handler"; import { handlers, type BroadcastFn, type HandlerContext, type ReloadWebviewFn, type ShowLogsFn } from "./handlers"; import { BaselineManager, type BaselineSession } from "./managers/baseline.manager"; import { FileManager } from "./managers/file.manager"; @@ -161,6 +163,7 @@ export class BridgeHandler { ...(sessionId === undefined ? {} : { sessionId }), }); this.fileManager.setSession(webviewId, baselineSession(runtime)); + void this.broadcastSlashCommands(webviewId); return runtime; }, resumeSession: async (sessionId) => { @@ -182,6 +185,7 @@ export class BridgeHandler { VSCodeSettings.yoloMode, ); this.fileManager.setSession(webviewId, baselineSession(runtime)); + void this.broadcastSlashCommands(webviewId); return runtime; }, closeSession: async () => { @@ -193,6 +197,20 @@ export class BridgeHandler { }; } + /** + * The skill catalog only exists once a session does, so the command list the + * Webview loaded at startup is missing every skill. Re-push it on session + * create/resume rather than making the Webview poll. + */ + private async broadcastSlashCommands(webviewId: string): Promise { + try { + const commands = await getSlashCommands(undefined, this.createContext(webviewId)); + this.broadcast(Events.SlashCommandsChanged, commands, webviewId); + } catch (error) { + this.logRuntimeError("Unable to refresh the slash commands", error); + } + } + private async saveAllDirty(): Promise { const dirty = vscode.workspace.textDocuments.filter((document) => document.isDirty && !document.isUntitled); await Promise.all(dirty.map((document) => document.save())); diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 944b4c30..088b564d 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { Events } from "../shared/bridge"; import { PythinkerWebviewProvider } from "./PythinkerWebviewProvider"; import { onSettingsChange, VSCodeSettings } from "./config/vscode-settings"; +import { defaultPermissionMode } from "./runtime/permission-mode"; import { updateLoginContext } from "./utils/context"; let outputChannel: vscode.OutputChannel | undefined; @@ -48,7 +49,7 @@ export async function activate(context: vscode.ExtensionContext): Promise }); if (changedKeys.includes("yoloMode")) { void provider - ?.setYoloModeForActiveSessions(VSCodeSettings.yoloMode) + ?.setPermissionModeForActiveSessions(defaultPermissionMode(VSCodeSettings.yoloMode)) .catch((error) => logError("Unable to update session permission", error)); } }), vscode.window.registerWebviewViewProvider("pythinker.webview", provider, { diff --git a/apps/vscode/src/handlers/chat.handler.ts b/apps/vscode/src/handlers/chat.handler.ts index 3870cf5c..ba46b4e0 100644 --- a/apps/vscode/src/handlers/chat.handler.ts +++ b/apps/vscode/src/handlers/chat.handler.ts @@ -124,7 +124,7 @@ const streamChat: Handler = async (params, return { done: false }; } - const slash = parseHostSlashCommand(params.content); + const slash = await parseHostSlashCommand(params.content, () => runtime.session.listSkills()); if (slash !== undefined) { try { return { done: await runHostSlashCommand(runtime, slash, ctx) }; diff --git a/apps/vscode/src/handlers/config.handler.ts b/apps/vscode/src/handlers/config.handler.ts index 116fd82a..4804b2eb 100644 --- a/apps/vscode/src/handlers/config.handler.ts +++ b/apps/vscode/src/handlers/config.handler.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode"; +import { buildSkillSlashCommands, type SkillSlashCommand } from "@pythoughts/pythinker-code-sdk"; type SdkConfig = any; import { Methods } from "../../shared/bridge"; @@ -16,11 +17,15 @@ const SLASH_COMMANDS: SlashCommandInfo[] = [ { name: "init", aliases: [], description: "Analyze the codebase and generate AGENTS.md" }, { name: "compact", aliases: [], description: "Compact the conversation context" }, { name: "clear", aliases: ["reset"], description: "Clear the context" }, - { name: "yolo", aliases: [], description: "Toggle YOLO mode (auto-approve tool actions; may still ask questions)" }, + { + name: "yolo", + aliases: [], + description: "Toggle YOLO mode (auto-approve tool actions; may still ask questions). Usage: /yolo [on|off]", + }, { name: "auto", aliases: ["afk"], - description: "Toggle Auto mode (fully autonomous; the agent will not ask questions)", + description: "Toggle Auto mode (fully autonomous; the agent will not ask questions). Usage: /auto [on|off]", }, { name: "plan", aliases: [], description: "Toggle plan mode. Usage: /plan [on|off|view|clear]" }, { @@ -83,25 +88,36 @@ const getModels: Handler = async (_, ctx) => { return toWebviewConfig(await ctx.harness.getConfig({ reload: true })); }; -const getSlashCommands: Handler = async (_, ctx) => { - if (!ctx.workDir) return SLASH_COMMANDS; +/** + * Skills are resolved from the workspace, not from a session, so a panel that + * has not sent a message yet still lists them. A live session is preferred when + * there is one: only it can report the prompts of its MCP connections. + */ +export const getSlashCommands: Handler = async (_, ctx) => { + const session = ctx.getSession()?.session; try { - const skills = await (ctx.harness as any).listWorkspaceSkills?.(ctx.workDir) ?? []; - const skillCommands = (skills as Array<{ name: string; type: string; description?: string }>) - .filter((skill) => isUserActivatableSkill(skill.type)) - .toSorted((left, right) => left.name.localeCompare(right.name)) - .map((skill) => ({ - name: `skill:${skill.name}`, - aliases: [], - description: skill.description ?? "", - })); - return [...SLASH_COMMANDS, ...skillCommands]; + const skills = + session !== undefined + ? await session.listSkills() + : ctx.workDir !== null + ? await ctx.harness.listWorkspaceSkills(ctx.workDir) + : []; + const { commands } = buildSkillSlashCommands(skills); + return [...SLASH_COMMANDS, ...commands.map(toSlashCommandInfo)]; } catch (error) { - ctx.logError("Unable to list workspace skills", error); + ctx.logError("Unable to list skills", error); return SLASH_COMMANDS; } }; +function toSlashCommandInfo(command: SkillSlashCommand): SlashCommandInfo { + return { + name: command.name, + aliases: [...command.aliases], + description: command.description, + }; +} + const showLogs: Handler = async (_, ctx) => { ctx.showLogs(); return { ok: true }; @@ -150,6 +166,3 @@ function toWebviewModel(id: string, model: any): ModelConfig { }; } -function isUserActivatableSkill(type: string | undefined): boolean { - return type === undefined || type === "prompt" || type === "inline" || type === "flow"; -} diff --git a/apps/vscode/src/handlers/index.ts b/apps/vscode/src/handlers/index.ts index fa0a72b1..9631d31f 100644 --- a/apps/vscode/src/handlers/index.ts +++ b/apps/vscode/src/handlers/index.ts @@ -1,5 +1,6 @@ import { configHandlers } from "./config.handler"; import { mcpHandlers } from "./mcp.handler"; +import { providerHandlers } from "./provider.handler"; import { sessionHandlers } from "./session.handler"; import { chatHandlers } from "./chat.handler"; import { fileHandlers } from "./file.handler"; @@ -13,6 +14,7 @@ export const handlers: Record> = { ...workspaceHandlers, ...configHandlers, ...mcpHandlers, + ...providerHandlers, ...sessionHandlers, ...chatHandlers, ...fileHandlers, diff --git a/apps/vscode/src/handlers/provider.handler.ts b/apps/vscode/src/handlers/provider.handler.ts new file mode 100644 index 00000000..c2675188 --- /dev/null +++ b/apps/vscode/src/handlers/provider.handler.ts @@ -0,0 +1,143 @@ +import { + CatalogProviderError, + DEFAULT_CATALOG_URL, + catalogConnectionWire, + catalogProviderModels, + fetchCatalog, + importCatalogProvider, + type Catalog, +} from "@pythoughts/pythinker-code-sdk"; + +import { Events, Methods } from "../../shared/bridge"; +import type { + AddCatalogProviderRequest, + CatalogProviderSummary, + ConfiguredProvider, + ProvidersView, +} from "../../shared/types"; +import type { Handler, HandlerContext } from "./types"; + +/** + * The catalog is a ~1 MB public document that changes rarely. One fetch per + * extension host is enough; the modal reads it repeatedly while the user browses. + */ +let catalogCache: Promise | undefined; + +async function loadCatalog(): Promise { + catalogCache ??= fetchCatalog(DEFAULT_CATALOG_URL).catch((error: unknown) => { + catalogCache = undefined; + throw error; + }); + return catalogCache; +} + +async function readProviders(ctx: HandlerContext): Promise { + const config = await ctx.harness.getConfig({ reload: true }); + const providers: ConfiguredProvider[] = Object.entries(config.providers ?? {}) + .map(([id, provider]) => toConfiguredProvider(id, provider, config)) + .toSorted((left, right) => left.id.localeCompare(right.id)); + return { providers, defaultModel: config.defaultModel ?? null }; +} + +function toConfiguredProvider(id: string, provider: any, config: any): ConfiguredProvider { + const models = Object.entries(config.models ?? {}) + .filter(([, alias]) => (alias as any).provider === id) + .map(([alias]) => alias); + return { + id, + type: provider.type ?? "unknown", + baseUrl: provider.baseUrl, + host: hostOf(provider.baseUrl), + // Never send the key itself to the Webview; only whether one is configured + // and where it comes from. A managed provider authenticates over OAuth and + // is required by the schema to carry no key at all, so it is not missing one. + keySource: + provider.oauth !== undefined + ? "oauth" + : typeof provider.apiKey === "string" && provider.apiKey.length > 0 + ? "config" + : typeof provider.apiKeyEnvVar === "string" && provider.apiKeyEnvVar.length > 0 + ? "env" + : "none", + apiKeyEnvVar: provider.apiKeyEnvVar, + catalogUrl: provider.source?.kind === "modelsDev" ? provider.source.url : undefined, + models: models.toSorted((left, right) => left.localeCompare(right)), + }; +} + +export const providerHandlers: Record> = { + [Methods.GetProviders]: async (_, ctx): Promise => readProviders(ctx), + + [Methods.GetProviderCatalog]: async (): Promise => { + const catalog = await loadCatalog(); + return Object.entries(catalog) + .map(([id, entry]) => toCatalogSummary(id, entry)) + // A provider that cannot be reached with a single API key has no path + // through this UI, so it is not offered. + .filter((entry) => entry.wire !== undefined && entry.models.length > 0) + .toSorted((left, right) => left.name.localeCompare(right.name)); + }, + + [Methods.AddCatalogProvider]: async ( + params: AddCatalogProviderRequest, + ctx, + ): Promise => { + const catalog = await loadCatalog(); + const entry = catalog[params.providerId]; + if (entry === undefined) { + throw new Error(`Provider "${params.providerId}" is not in the catalog.`); + } + try { + await importCatalogProvider(ctx.harness, { + providerId: params.providerId, + entry, + catalogUrl: DEFAULT_CATALOG_URL, + apiKey: params.apiKey, + apiKeyEnvVar: params.apiKeyEnvVar, + defaultModel: params.defaultModel, + }); + } catch (error) { + // The import errors are already written for a person to read. + if (error instanceof CatalogProviderError) throw new Error(error.message, { cause: error }); + throw error; + } + const view = await readProviders(ctx); + ctx.broadcast(Events.ProvidersChanged, view); + return view; + }, + + [Methods.RemoveProvider]: async ( + { providerId }: { providerId: string }, + ctx, + ): Promise => { + await ctx.harness.removeProvider(providerId); + const view = await readProviders(ctx); + ctx.broadcast(Events.ProvidersChanged, view); + return view; + }, +}; + +function hostOf(baseUrl: unknown): string | undefined { + if (typeof baseUrl !== "string" || baseUrl.length === 0) return undefined; + try { + return new URL(baseUrl).host; + } catch { + return undefined; + } +} + +function toCatalogSummary(id: string, entry: any): CatalogProviderSummary { + const wire = catalogConnectionWire(entry); + return { + id, + name: entry.name ?? id, + wire, + apiKeyEnvVar: entry.env?.[0], + models: catalogProviderModels(entry).map((model) => ({ + id: model.id, + name: model.name ?? model.id, + maxContextTokens: model.capability?.max_context_tokens, + thinking: model.capability?.thinking === true, + })), + }; +} diff --git a/apps/vscode/src/handlers/slash-command.ts b/apps/vscode/src/handlers/slash-command.ts index 8fefcc55..f9ccafe3 100644 --- a/apps/vscode/src/handlers/slash-command.ts +++ b/apps/vscode/src/handlers/slash-command.ts @@ -4,6 +4,12 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import * as vscode from "vscode"; +import { + buildSkillSlashCommands, + type PermissionMode, + type SkillSummary, +} from "@pythoughts/pythinker-code-sdk"; + import type { SessionRuntime } from "../runtime/session-runtime"; import { buildExportMarkdown, @@ -32,16 +38,40 @@ export interface HostSlashCommand { readonly name: string; readonly args: string; readonly raw: string; + /** Set when the command names a skill; built-in skills are not `skill:`-prefixed. */ + readonly skillName?: string; } -export function parseHostSlashCommand(content: string | readonly unknown[]): HostSlashCommand | undefined { +/** + * `listSkills` is consulted only for a `/word` that is not a host command, so an + * ordinary message never pays for it. Anything that resolves to neither a host + * command nor a skill is left alone and goes to the model as text. + */ +export async function parseHostSlashCommand( + content: string | readonly unknown[], + listSkills?: () => Promise, +): Promise { if (typeof content !== "string") return undefined; const raw = content.trim(); const match = /^\/([^\s]+)(?:\s+(.*))?\s*$/s.exec(raw); if (match === null) return undefined; const name = match[1]!.toLowerCase(); - if (!HOST_COMMANDS.has(name) && !name.startsWith("skill:")) return undefined; - return { name, args: match[2]?.trim() ?? "", raw }; + const args = match[2]?.trim() ?? ""; + if (HOST_COMMANDS.has(name)) return { name, args, raw }; + + const skills = listSkills === undefined ? undefined : await listSkills().catch(() => undefined); + if (skills === undefined) { + // The parser runs on every message that starts with "/", so a catalog + // failure must degrade to the prefix check rather than reject and take the + // whole send down with it. + return name.startsWith("skill:") ? { name, args, raw, skillName: name.slice(6) } : undefined; + } + const { commandMap } = buildSkillSlashCommands(skills); + const skillName = commandMap.get(name) ?? commandMap.get(match[1]!); + if (skillName !== undefined) return { name, args, raw, skillName }; + // A skill the catalog no longer lists still reaches the engine, which reports + // the miss far better than silently sending "/skill:foo" to the model. + return name.startsWith("skill:") ? { name, args, raw, skillName: name.slice(6) } : undefined; } export async function runHostSlashCommand( @@ -49,8 +79,8 @@ export async function runHostSlashCommand( command: HostSlashCommand, ctx: HandlerContext, ): Promise { - if (command.name.startsWith("skill:")) { - const skillName = command.name.slice("skill:".length); + if (command.skillName !== undefined) { + const skillName = command.skillName; const result = await runtime.runTurnAction(command.raw, async () => { await runtime.session.activateSkill(skillName, command.args || undefined); }); @@ -84,11 +114,11 @@ export async function runHostSlashCommand( emit("The context has been cleared."); break; case "yolo": - await toggleLegacyPermission(runtime, "yolo", emit); + await runPermissionCommand(runtime, "yolo", command.args, emit); break; case "auto": case "afk": - await toggleLegacyPermission(runtime, "afk", emit); + await runPermissionCommand(runtime, "auto", command.args, emit); break; case "plan": await runPlanCommand(runtime, command.args, emit); @@ -114,26 +144,49 @@ export async function runHostSlashCommand( } } -async function toggleLegacyPermission( +const PERMISSION_MODE_ENABLED_MESSAGE = { + yolo: "You only live once! Tool actions will be auto-approved; the agent may still ask questions.", + auto: "Auto mode enabled. Questions will be auto-dismissed and tool calls auto-approved.", +} as const; + +const PERMISSION_MODE_DISABLED_MESSAGE = { + yolo: "You only die once! Actions will require approval.", + auto: "Auto mode disabled. You are back at the keyboard.", +} as const; + +/** `/yolo` and `/auto` accept `on` and `off`, and toggle without an argument — as the CLI does. */ +async function runPermissionCommand( runtime: SessionRuntime, - kind: "yolo" | "afk", + mode: "yolo" | "auto", + args: string, emit: (text: string) => void, ): Promise { - const flags = await runtime.toggleLegacyApproval(kind); + const subcommand = args.trim().toLowerCase(); + const requested = + subcommand === "on" ? mode : subcommand === "off" ? "manual" : undefined; - if (kind === "yolo") { - emit(flags.yolo - ? "You only live once! Tool actions will be auto-approved; the agent may still ask questions." - : flags.afk - ? "Yolo disabled, but Auto is still on — tool calls remain auto-approved." - : "You only die once! Actions will require approval."); + if (requested !== undefined && runtime.permissionMode === requested) { + emit(requested === mode ? `${label(mode)} is already on.` : `${label(mode)} is already off.`); return; } - emit(flags.afk - ? "Auto mode enabled. Questions will be auto-dismissed and tool calls auto-approved." - : flags.yolo - ? "Auto mode disabled. You are back at the keyboard. Yolo is still on." - : "Auto mode disabled. You are back at the keyboard."); + + let current: PermissionMode; + if (requested === undefined) { + current = await runtime.togglePermissionMode(mode); + } else { + await runtime.setPermissionMode(requested); + current = requested; + } + + emit( + current === mode + ? PERMISSION_MODE_ENABLED_MESSAGE[mode] + : PERMISSION_MODE_DISABLED_MESSAGE[mode], + ); +} + +function label(mode: "yolo" | "auto"): string { + return mode === "yolo" ? "YOLO mode" : "Auto mode"; } async function runPlanCommand( diff --git a/apps/vscode/src/runtime/event-adapter.ts b/apps/vscode/src/runtime/event-adapter.ts index dfefa119..0cfa7c32 100644 --- a/apps/vscode/src/runtime/event-adapter.ts +++ b/apps/vscode/src/runtime/event-adapter.ts @@ -4,6 +4,7 @@ import type { DisplayBlock, LegacyWireEvent, StatusUpdate, + SubagentStatusPayload, TokenUsage, TurnBegin, } from '../../shared/legacy-sdk'; @@ -22,6 +23,9 @@ export interface AdapterTokenUsage { export interface SubagentParent { readonly parentAgentId: string; readonly parentToolCallId: string; + readonly subagentName?: string; + readonly description?: string; + readonly dynamicWorkflowIndex?: number; } export interface EventAdapterState { @@ -89,22 +93,35 @@ export function adaptSdkEvent( if (sdkEvent.type === 'subagent.spawned') { const parentAgentId = (sdkEvent as any).parentAgentId ?? (sdkEvent as any).callerAgentId ?? sdkEvent.agentId; - return { - state: { - ...state, - subagentParents: { - ...state.subagentParents, - [sdkEvent.subagentId]: { - parentAgentId, - parentToolCallId: scopedToolCallId( - parentAgentId, - sdkEvent.parentToolCallId, - mainAgentId, - ), - }, + const parentToolCallId = scopedToolCallId(parentAgentId, sdkEvent.parentToolCallId, mainAgentId); + const nextState: EventAdapterState = { + ...state, + subagentParents: { + ...state.subagentParents, + [sdkEvent.subagentId]: { + parentAgentId, + parentToolCallId, + subagentName: sdkEvent.subagentName, + description: sdkEvent.description, + dynamicWorkflowIndex: sdkEvent.dynamicWorkflowIndex, }, }, }; + const statusPayload: SubagentStatusPayload = { + parent_tool_call_id: parentToolCallId, + agent_id: sdkEvent.subagentId, + agent_label: sdkEvent.subagentName, + agent_index: sdkEvent.dynamicWorkflowIndex, + status: 'spawned', + }; + const routed = routeSubagentEvent( + nextState, + parentAgentId, + { type: 'SubagentStatus', payload: statusPayload }, + mainAgentId, + ); + if (routed === undefined) return { state: nextState }; + return { state: nextState, event: withSessionId(routed, sdkEvent.sessionId) }; } if (sdkEvent.type === 'turn.started') { @@ -320,6 +337,14 @@ function mapLegacyWireEvent( } case 'agent.status.updated': return mapStatusUpdate(state, sdkEvent); + case 'subagent.started': + return mapSubagentStatus(state, sdkEvent, 'running'); + case 'subagent.completed': + return mapSubagentStatus(state, sdkEvent, 'done'); + case 'subagent.failed': + return mapSubagentStatus(state, sdkEvent, 'failed'); + case 'subagent.suspended': + return mapSubagentStatus(state, sdkEvent, 'suspended'); case 'compaction.started': return { state, @@ -368,6 +393,32 @@ function mapStatusUpdate( }; } +function mapSubagentStatus( + state: EventAdapterState, + sdkEvent: Extract< + Event, + { type: 'subagent.started' | 'subagent.completed' | 'subagent.failed' | 'subagent.suspended' } + >, + status: SubagentStatusPayload['status'], +): MappedLegacyWireEvent { + // subagent.spawned always precedes every other lifecycle event for the same + // subagentId, so the parent is always known by the time this runs. + const parent = state.subagentParents[sdkEvent.subagentId]; + if (parent === undefined) return { state }; + + const payload: SubagentStatusPayload = { + parent_tool_call_id: parent.parentToolCallId, + agent_id: sdkEvent.subagentId, + agent_label: parent.subagentName, + agent_index: parent.dynamicWorkflowIndex, + status, + error: sdkEvent.type === 'subagent.failed' ? sdkEvent.error : undefined, + result_summary: sdkEvent.type === 'subagent.completed' ? sdkEvent.resultSummary : undefined, + }; + + return { state, event: { type: 'SubagentStatus', payload } }; +} + function usageDelta(current: AdapterTokenUsage, previous: AdapterTokenUsage | undefined): TokenUsage { return { input_other: delta(current.inputOther, previous?.inputOther), @@ -414,6 +465,9 @@ function routeSubagentEvent( type: 'SubagentEvent', payload: { parent_tool_call_id: parent.parentToolCallId, + agent_id: currentAgentId, + agent_label: parent.subagentName, + agent_index: parent.dynamicWorkflowIndex, event: routed, }, }; diff --git a/apps/vscode/src/runtime/legacy-approval.ts b/apps/vscode/src/runtime/legacy-approval.ts deleted file mode 100644 index ab3fa850..00000000 --- a/apps/vscode/src/runtime/legacy-approval.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { JsonObject, PermissionMode } from "@pythoughts/pythinker-code-sdk"; - -export const LEGACY_APPROVAL_METADATA_KEY = "vscode_legacy_approval"; - -export interface LegacyApprovalFlags { - readonly yolo: boolean; - readonly afk: boolean; -} - -export function readLegacyApprovalFlags( - metadata: Readonly> | undefined, -): LegacyApprovalFlags | undefined { - const value = metadata?.[LEGACY_APPROVAL_METADATA_KEY]; - return parseLegacyApprovalFlags(value); -} - -function parseLegacyApprovalFlags(value: unknown): LegacyApprovalFlags | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; - const yolo = Reflect.get(value, "yolo"); - const afk = Reflect.get(value, "afk"); - if (typeof yolo !== "boolean" && typeof afk !== "boolean") return undefined; - return { - yolo: typeof yolo === "boolean" ? yolo : false, - afk: typeof afk === "boolean" ? afk : false, - }; -} - -export function legacyApprovalMetadata(flags: LegacyApprovalFlags): JsonObject { - return { - [LEGACY_APPROVAL_METADATA_KEY]: { - yolo: flags.yolo, - afk: flags.afk, - }, - }; -} - -export function corePermissionForLegacyApproval(flags: LegacyApprovalFlags): PermissionMode { - if (flags.afk) return "auto"; - return flags.yolo ? "yolo" : "manual"; -} - -/** - * The global `pythinker.yoloMode` setting is authoritative whenever a session - * attaches to the runtime; afk stays per-session because it has no global - * setting counterpart. - */ -export function withGlobalYoloMode( - flags: LegacyApprovalFlags, - yoloMode: boolean, -): LegacyApprovalFlags { - return flags.yolo === yoloMode ? flags : { yolo: yoloMode, afk: flags.afk }; -} diff --git a/apps/vscode/src/runtime/permission-mode.ts b/apps/vscode/src/runtime/permission-mode.ts new file mode 100644 index 00000000..1b142b44 --- /dev/null +++ b/apps/vscode/src/runtime/permission-mode.ts @@ -0,0 +1,45 @@ +import type { JsonObject, PermissionMode, Session } from "@pythoughts/pythinker-code-sdk"; + +/** + * The engine keeps the permission mode in memory only, so a resumed session + * would always come back as `manual`. The extension persists the mode the user + * chose in session metadata and restores it on attach. + */ +export const SESSION_PERMISSION_MODE_KEY = "vscode_permission_mode"; + +const PERMISSION_MODES: ReadonlySet = new Set(["manual", "auto", "yolo"]); + +export function isPermissionMode(value: unknown): value is PermissionMode { + return typeof value === "string" && PERMISSION_MODES.has(value as PermissionMode); +} + +export function readPermissionMode( + metadata: Readonly> | undefined, +): PermissionMode | undefined { + const value = metadata?.[SESSION_PERMISSION_MODE_KEY]; + return isPermissionMode(value) ? value : undefined; +} + +export function permissionModeMetadata(mode: PermissionMode): JsonObject { + return { [SESSION_PERMISSION_MODE_KEY]: mode }; +} + +/** + * The session-meta patch is a shallow merge, and the caller-owned bag it lands + * in is `custom` — surfaced as `summary.metadata` on the read side. The whole + * bag is rewritten so the keys it already holds survive the patch. + */ +export async function persistPermissionMode( + session: Session, + mode: PermissionMode, +): Promise { + const meta = await session.getSessionMetadata(); + await session.updateSessionMetadata({ + custom: { ...meta.custom, ...permissionModeMetadata(mode) }, + }); +} + +/** The `pythinker.yoloMode` setting seeds new sessions; it never overrides a stored mode. */ +export function defaultPermissionMode(yoloModeSetting: boolean): PermissionMode { + return yoloModeSetting ? "yolo" : "manual"; +} diff --git a/apps/vscode/src/runtime/pythinker-runtime.ts b/apps/vscode/src/runtime/pythinker-runtime.ts index 29443438..04340115 100644 --- a/apps/vscode/src/runtime/pythinker-runtime.ts +++ b/apps/vscode/src/runtime/pythinker-runtime.ts @@ -1,5 +1,6 @@ import { createPythinkerHarness, + type PermissionMode, type PythinkerHarness, type Session, type SessionSummary, @@ -7,12 +8,11 @@ import { import type { RuntimeBroadcast } from "./session-runtime"; import { - corePermissionForLegacyApproval, - legacyApprovalMetadata, - readLegacyApprovalFlags, - withGlobalYoloMode, - type LegacyApprovalFlags, -} from "./legacy-approval"; + defaultPermissionMode, + permissionModeMetadata, + persistPermissionMode, + readPermissionMode, +} from "./permission-mode"; import { SessionRuntime } from "./session-runtime"; import { areSameFsPath } from "../utils/fs-path"; @@ -84,7 +84,7 @@ export class PythinkerRuntime { requestedId === current.id && areSameFsPath(current.session.workDir, options.workDir) ) { - await applySessionSettings(current.session, options, current.legacyApprovalFlags); + await applySessionPermission(current.session, current.permissionMode); await current.announceStatus(options.webviewId); return current; } @@ -92,31 +92,25 @@ export class PythinkerRuntime { let runtime = requestedId === undefined ? undefined : this.sessions.get(requestedId); if (runtime !== undefined) { assertSessionWorkDir(runtime.session, options.workDir); - await applySessionSettings(runtime.session, options, runtime.legacyApprovalFlags); + await applySessionPermission(runtime.session, runtime.permissionMode); await this.detachView(options.webviewId); } else { - const defaultApproval: LegacyApprovalFlags = { yolo: options.yoloMode, afk: false }; + const seedMode = defaultPermissionMode(options.yoloMode); const session = requestedId === undefined ? await this.harness.createSession({ workDir: options.workDir, model: options.model || undefined, thinking: normalizeEffort(options.effort), - permission: corePermissionForLegacyApproval(defaultApproval), - metadata: legacyApprovalMetadata(defaultApproval), + permission: seedMode, + metadata: permissionModeMetadata(seedMode), }) : await this.harness.resumeSession({ id: requestedId }); try { assertSessionWorkDir(session, options.workDir); - const storedApproval = readLegacyApprovalFlags(session.summary?.metadata); - const restoredApproval = storedApproval ?? defaultApproval; - const approval = withGlobalYoloMode(restoredApproval, options.yoloMode); - if (storedApproval === undefined || flagsDiffer(storedApproval, approval)) { - await (session as any).updateMetadata?.(legacyApprovalMetadata(approval)); - } - await applySessionSettings(session, options, approval); + const mode = await restorePermissionMode(session, seedMode); await this.detachView(options.webviewId); - runtime = this.wrapSession(session, approval); + runtime = this.wrapSession(session, mode); } catch (error) { await session.close().catch((closeError: unknown) => { this.log("Failed to close a rejected session", closeError); @@ -134,7 +128,7 @@ export class PythinkerRuntime { async attachResumedSession( webviewId: string, session: Session, - defaultYoloMode = false, + yoloModeSetting = false, ): Promise { const existing = this.sessions.get(session.id); if (existing !== undefined && this.sessionByView.get(webviewId) === session.id) { @@ -146,16 +140,8 @@ export class PythinkerRuntime { let runtime = existing ?? this.sessions.get(session.id); if (runtime === undefined) { try { - const storedApproval = readLegacyApprovalFlags(session.summary?.metadata); - const restoredApproval = storedApproval ?? { yolo: defaultYoloMode, afk: false }; - const approval = withGlobalYoloMode(restoredApproval, defaultYoloMode); - if (storedApproval === undefined || flagsDiffer(storedApproval, approval)) { - await (session as any).updateMetadata?.(legacyApprovalMetadata(approval)); - } - const status = await session.getStatus(); - const permission = corePermissionForLegacyApproval(approval); - if (status.permission !== permission) await session.setPermission(permission); - runtime = this.wrapSession(session, approval); + const mode = await restorePermissionMode(session, defaultPermissionMode(yoloModeSetting)); + runtime = this.wrapSession(session, mode); } catch (error) { await session.close().catch((closeError: unknown) => { this.log("Failed to close a rejected session", closeError); @@ -200,9 +186,13 @@ export class PythinkerRuntime { await ((this.harness as any).deleteSession?.(id) ?? Promise.resolve()); } - async setYoloModeForActiveSessions(enabled: boolean): Promise { + /** + * Applies an explicit settings change to the live sessions. Attach and resume + * never do this — they restore whatever mode the session was left in. + */ + async setPermissionModeForActiveSessions(mode: PermissionMode): Promise { await Promise.all( - [...this.sessions.values()].map((session) => session.setLegacyYoloMode(enabled)), + [...this.sessions.values()].map((session) => session.setPermissionMode(mode)), ); } @@ -215,10 +205,10 @@ export class PythinkerRuntime { await this.harness.close(); } - private wrapSession(session: Session, legacyApproval: LegacyApprovalFlags): SessionRuntime { + private wrapSession(session: Session, permissionMode: PermissionMode): SessionRuntime { const runtime = new SessionRuntime({ session, - legacyApproval, + permissionMode, broadcast: this.broadcast, captureBaseline: this.captureBaseline, log: this.log, @@ -232,24 +222,31 @@ export class PythinkerRuntime { } } -async function applySessionSettings( +/** + * The engine forgets the permission mode when a session is resumed, so the + * stored mode is authoritative and the seed only covers sessions that have + * never recorded one. + */ +async function restorePermissionMode( session: Session, - options: OpenSessionOptions, - legacyApproval: LegacyApprovalFlags, -): Promise { - const status = await session.getStatus(); - const permission = corePermissionForLegacyApproval(legacyApproval); - if (status.permission !== permission) { - await session.setPermission(permission); + seedMode: PermissionMode, +): Promise { + const storedMode = readPermissionMode(session.summary?.metadata); + const mode = storedMode ?? seedMode; + if (storedMode === undefined) { + await persistPermissionMode(session, mode); } + await applySessionPermission(session, mode); + return mode; } -export function normalizeEffort(effort: string): string { - return effort.trim() || "off"; +async function applySessionPermission(session: Session, mode: PermissionMode): Promise { + const status = await session.getStatus(); + if (status.permission !== mode) await session.setPermission(mode); } -function flagsDiffer(a: LegacyApprovalFlags, b: LegacyApprovalFlags): boolean { - return a.yolo !== b.yolo || a.afk !== b.afk; +export function normalizeEffort(effort: string): string { + return effort.trim() || "off"; } function assertSessionWorkDir(session: Pick, expectedWorkDir: string): void { diff --git a/apps/vscode/src/runtime/replay-adapter.ts b/apps/vscode/src/runtime/replay-adapter.ts index bd51f226..83f544bf 100644 --- a/apps/vscode/src/runtime/replay-adapter.ts +++ b/apps/vscode/src/runtime/replay-adapter.ts @@ -496,6 +496,9 @@ function wrapSubagentEvent( invocation.parentAgentId, invocation.parentToolCallId, ), + // Replay history carries no persisted label or dynamicWorkflowIndex; the UI + // falls back to the id's short form. + agent_id: invocation.childAgentId, event: routed, }, }; diff --git a/apps/vscode/src/runtime/session-runtime.ts b/apps/vscode/src/runtime/session-runtime.ts index f12d6769..cf16e44c 100644 --- a/apps/vscode/src/runtime/session-runtime.ts +++ b/apps/vscode/src/runtime/session-runtime.ts @@ -2,6 +2,7 @@ import { isPythinkerError, type ContentPart as SdkContentPart, type Event, + type PermissionMode, type PromptInput, type Session, type SessionSummary, @@ -17,14 +18,14 @@ import { type EventAdapterState, type TurnTerminalMetadata, } from "./event-adapter"; -import { corePermissionForLegacyApproval, type LegacyApprovalFlags } from "./legacy-approval"; +import { persistPermissionMode } from "./permission-mode"; import { ReverseRpcController } from "./reverse-rpc"; export type RuntimeBroadcast = (event: string, data: unknown, webviewId?: string) => void; export interface SessionRuntimeOptions { readonly session: Session; - readonly legacyApproval: LegacyApprovalFlags; + readonly permissionMode: PermissionMode; readonly broadcast: RuntimeBroadcast; readonly captureBaseline: ( session: Pick, @@ -83,7 +84,7 @@ export class SessionRuntime { private exclusiveActionActive = false; private readonly terminalKeys = new Set(); private suppressedError: SuppressedError | undefined; - private legacyApproval: LegacyApprovalFlags; + private currentPermissionMode: PermissionMode; private closed = false; constructor(options: SessionRuntimeOptions) { @@ -91,13 +92,13 @@ export class SessionRuntime { this.broadcast = options.broadcast; this.captureBaseline = options.captureBaseline; this.log = options.log; - this.legacyApproval = options.legacyApproval; + this.currentPermissionMode = options.permissionMode; this.reverseRpc = new ReverseRpcController((event) => this.emitStreamEvent(event)); // Forward every approval request to the user. The engine permission mode - // (mapped from the legacy flags) already auto-approves what yolo/auto - // allow internally; anything that reaches this handler is an exception - // (sensitive file, plan review, ask rule) the user must decide on. + // already auto-approves what yolo/auto allow internally; anything that + // reaches this handler is an exception (sensitive file, plan review, ask + // rule) the user must decide on. this.session.setApprovalHandler((request) => this.reverseRpc.requestApproval(request)); this.session.setQuestionHandler((request) => this.reverseRpc.requestQuestion(request)); this.unsubscribe = this.session.onEvent((event) => this.onSdkEvent(event)); @@ -119,19 +120,24 @@ export class SessionRuntime { return this.hasActiveWork || this.exclusiveActionActive; } - get legacyApprovalFlags(): LegacyApprovalFlags { - return this.legacyApproval; + get permissionMode(): PermissionMode { + return this.currentPermissionMode; } - async toggleLegacyApproval(kind: keyof LegacyApprovalFlags): Promise { - const next = { ...this.legacyApproval, [kind]: !this.legacyApproval[kind] }; - await this.applyLegacyApproval(next); + /** Toggles between `mode` and `manual`, and returns the mode now in effect. */ + async togglePermissionMode(mode: Exclude): Promise { + const next = this.currentPermissionMode === mode ? "manual" : mode; + await this.setPermissionMode(next); return next; } - async setLegacyYoloMode(enabled: boolean): Promise { - if (this.legacyApproval.yolo === enabled) return; - await this.applyLegacyApproval({ ...this.legacyApproval, yolo: enabled }); + async setPermissionMode(mode: PermissionMode): Promise { + if (this.currentPermissionMode === mode) return; + this.ensureOpen(); + const status = await this.session.getStatus(); + if (status.permission !== mode) await this.session.setPermission(mode); + await persistPermissionMode(this.session, mode); + this.currentPermissionMode = mode; } subscribe(webviewId: string): void { @@ -413,15 +419,6 @@ export class SessionRuntime { this.webviewIds.clear(); } - private async applyLegacyApproval(flags: LegacyApprovalFlags): Promise { - this.ensureOpen(); - const permission = corePermissionForLegacyApproval(flags); - const status = await this.session.getStatus(); - const permissionChanged = status.permission !== permission; - if (permissionChanged) await this.session.setPermission(permission); - this.legacyApproval = flags; - } - private onSdkEvent(event: Event): void { if (this.closed) return; diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index 648662c4..40330c30 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -26,6 +26,8 @@ const host = vi.hoisted(() => { close: vi.fn(async () => undefined), getConfig: vi.fn(), setConfig: vi.fn(async () => undefined), + ensureConfigFile: vi.fn(async () => undefined), + removeProvider: vi.fn(async () => undefined), listSessions: vi.fn(async () => []), resumeSession: vi.fn(), forkSession: vi.fn(), @@ -430,7 +432,7 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () => describe("Webview config saves (thinking effort persistence parity with the TUI)", () => { const effortModel = { - provider: "managed:pythinker-code", + provider: "managed:kimi-code", model: "reasoning", supportEfforts: ["low", "high", "max"], defaultEffort: "high", @@ -521,7 +523,7 @@ function createResumedSession(id: string, workDir: string) { sessionDir: join("/private/pythinker/sessions", id), createdAt: 1, updatedAt: 2, - metadata: { vscode_legacy_approval: { yolo: false, afk: false } }, + metadata: { vscode_permission_mode: "manual" }, }; return { id, @@ -559,9 +561,105 @@ function createResumedSession(id: string, workDir: string) { }), getStatus: async () => ({ permission: "manual" }), setPermission: async () => undefined, - updateMetadata: async () => undefined, + getSessionMetadata: async () => ({ custom: {} }), + updateSessionMetadata: async () => undefined, setApprovalHandler: () => undefined, setQuestionHandler: () => undefined, onEvent: () => () => undefined, }; } + +describe("Webview provider management (writes the same config.toml the CLI reads)", () => { + const catalog = { + anthropic: { + id: "anthropic", + name: "Anthropic", + api: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + env: ["ANTHROPIC_API_KEY"], + models: { m1: { id: "m1", name: "M1", limit: { context: 200000, output: 64000 } } }, + }, + unusable: { id: "unusable", name: "Unusable", models: {} }, + }; + + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify(catalog), { status: 200 })), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("lists configured providers without ever sending the API key", async () => { + host.harness.getConfig.mockResolvedValue({ + providers: { anthropic: { type: "anthropic", apiKey: "sk-secret", baseUrl: "https://api.anthropic.com" } }, + models: { "anthropic/m1": { provider: "anthropic", model: "m1" } }, + defaultModel: "anthropic/m1", + } as never); + + const response = await bridge.handle({ id: "rpc-1", method: Methods.GetProviders }, "view-1"); + + const result = (response as { result: any }).result; + expect(result.providers).toEqual([ + expect.objectContaining({ id: "anthropic", keySource: "config", models: ["anthropic/m1"] }), + ]); + expect(JSON.stringify(result)).not.toContain("sk-secret"); + }); + + it("offers only catalog providers that a single key can reach", async () => { + const response = await bridge.handle({ id: "rpc-1", method: Methods.GetProviderCatalog }, "view-1"); + + const result = (response as { result: any }).result; + expect(result.map((entry: any) => entry.id)).toEqual(["anthropic"]); + expect(result[0].models).toEqual([expect.objectContaining({ id: "m1" })]); + }); + + it("imports a catalog provider into the config", async () => { + host.harness.getConfig.mockResolvedValue({ providers: {} } as never); + + const response = await bridge.handle( + { + id: "rpc-1", + method: Methods.AddCatalogProvider, + params: { providerId: "anthropic", apiKey: "sk-test", defaultModel: "m1" }, + }, + "view-1", + ); + + expect((response as { error?: unknown }).error).toBeUndefined(); + expect(host.harness.setConfig).toHaveBeenCalledWith( + expect.objectContaining({ + providers: expect.objectContaining({ + anthropic: expect.objectContaining({ apiKey: "sk-test" }), + }), + defaultModel: "anthropic/m1", + }), + ); + }); + + it("rejects an import with no key rather than writing a broken provider", async () => { + host.harness.getConfig.mockResolvedValue({ providers: {} } as never); + + const response = await bridge.handle( + { id: "rpc-1", method: Methods.AddCatalogProvider, params: { providerId: "anthropic" } }, + "view-1", + ); + + expect((response as { error?: string }).error).toMatch(/needs an API key/); + expect(host.harness.setConfig).not.toHaveBeenCalled(); + }); + + it("removes a provider through the harness", async () => { + host.harness.getConfig.mockResolvedValue({ providers: {} } as never); + + await bridge.handle( + { id: "rpc-1", method: Methods.RemoveProvider, params: { providerId: "anthropic" } }, + "view-1", + ); + + expect(host.harness.removeProvider).toHaveBeenCalledWith("anthropic"); + }); +}); diff --git a/apps/vscode/test/event-adapter.test.ts b/apps/vscode/test/event-adapter.test.ts index e910edfa..04a18520 100644 --- a/apps/vscode/test/event-adapter.test.ts +++ b/apps/vscode/test/event-adapter.test.ts @@ -360,6 +360,8 @@ describe('event adapter (projects SDK events into the legacy Webview contract)', type: 'SubagentEvent', payload: { parent_tool_call_id: 'agent-call-1', + agent_id: 'child-1', + agent_label: 'coder', event: { type: 'ContentPart', payload: { type: 'text', text: 'Child result' }, @@ -394,6 +396,8 @@ describe('event adapter (projects SDK events into the legacy Webview contract)', type: 'SubagentEvent', payload: { parent_tool_call_id: 'agent-call-1', + agent_id: 'child-1', + agent_label: 'coder', event: { type: 'ToolCall', payload: { @@ -410,6 +414,76 @@ describe('event adapter (projects SDK events into the legacy Webview contract)', }); }); + it('emits a spawned SubagentStatus the moment a subagent is queued', () => { + const result = adaptSdkEvent(createEventAdapterState(), { + type: 'subagent.spawned', + sessionId: 'session-1', + agentId: 'main', + subagentId: 'child-1', + subagentName: 'explore', + parentToolCallId: 'wf-1', + parentAgentId: 'main', + dynamicWorkflowIndex: 2, + runInBackground: false, + }); + + expect(result.event).toEqual({ + type: 'SubagentStatus', + payload: { + parent_tool_call_id: 'wf-1', + agent_id: 'child-1', + agent_label: 'explore', + agent_index: 2, + status: 'spawned', + }, + _sessionId: 'session-1', + }); + }); + + it.each([ + ['subagent.started' as const, 'running' as const, {}], + ['subagent.suspended' as const, 'suspended' as const, { reason: 'waiting on approval' }], + ['subagent.completed' as const, 'done' as const, { resultSummary: 'Explored 3 files' }], + ['subagent.failed' as const, 'failed' as const, { error: 'timed out' }], + ])('routes each %s lifecycle event to one SubagentStatus on the spawning tool call', (sdkType, status, extra) => { + const spawned = adaptSdkEvent(createEventAdapterState(), { + type: 'subagent.spawned', + sessionId: 'session-1', + agentId: 'main', + subagentId: 'child-1', + subagentName: 'explore', + parentToolCallId: 'wf-1', + parentAgentId: 'main', + dynamicWorkflowIndex: 2, + runInBackground: false, + }); + + const lifecycle = adaptSdkEvent(spawned.state, { + type: sdkType, + sessionId: 'session-1', + agentId: 'main', + subagentId: 'child-1', + parentToolCallId: 'wf-1', + ...extra, + } as any); + + const expectedPayload: Record = { + parent_tool_call_id: 'wf-1', + agent_id: 'child-1', + agent_label: 'explore', + agent_index: 2, + status, + }; + if ('error' in extra) expectedPayload['error'] = (extra as { error: string }).error; + if ('resultSummary' in extra) expectedPayload['result_summary'] = (extra as { resultSummary: string }).resultSummary; + + expect(lifecycle.event).toEqual({ + type: 'SubagentStatus', + payload: expectedPayload, + _sessionId: 'session-1', + }); + }); + it('emits compaction begin when SDK compaction starts', () => { const result = adaptSdkEvent(createEventAdapterState(), { type: 'compaction.started', diff --git a/apps/vscode/test/event-handlers.test.ts b/apps/vscode/test/event-handlers.test.ts new file mode 100644 index 00000000..a82ac0ff --- /dev/null +++ b/apps/vscode/test/event-handlers.test.ts @@ -0,0 +1,272 @@ +/** + * Scenario: `DynamicWorkflow` batches stream events for many subagents that share one + * parent tool call. Store-level event handling must attribute each agent's steps to that + * agent alone, and per-agent status/lane derivation must reflect the wire protocol. + * Wiring: the real Zustand chat store; the VS Code bridge is the only replaced boundary. + * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts test/event-handlers.test.ts + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useChatStore } from "../webview-ui/src/stores/chat.store"; +import { deriveWorkflowLanes, maxLaneStepCount } from "../webview-ui/src/lib/workflow-lanes"; +import type { UIStepItem } from "../webview-ui/src/stores/chat.store"; + +const boundary = vi.hoisted(() => ({ + saveConfig: vi.fn(), + streamChat: vi.fn(), + abortChat: vi.fn(), + trackFiles: vi.fn(), + toastError: vi.fn(), + toastWarning: vi.fn(), +})); + +vi.mock("@/services", () => ({ + bridge: { + saveConfig: boundary.saveConfig, + streamChat: boundary.streamChat, + abortChat: boundary.abortChat, + trackFiles: boundary.trackFiles, + }, +})); +vi.mock("@/components/ui/sonner", () => ({ + toast: { error: boundary.toastError, warning: boundary.toastWarning }, +})); + +beforeEach(() => { + boundary.streamChat.mockReset(); + boundary.abortChat.mockReset(); + boundary.trackFiles.mockReset(); + useChatStore.setState({ + sessionId: null, + messages: [], + isStreaming: false, + isCompacting: false, + handshakeReceived: false, + draftMedia: [], + lastStatus: null, + tokenUsage: { input_other: 0, output: 0, input_cache_read: 0, input_cache_creation: 0 }, + activeTokenUsage: { input_other: 0, output: 0, input_cache_read: 0, input_cache_creation: 0 }, + pendingInput: null, + queue: [], + pendingQuestion: null, + planMode: false, + }); +}); + +function startWorkflowTurn() { + useChatStore.getState().processEvent({ type: "TurnBegin", payload: { user_input: "run workflow" } }); + useChatStore.getState().processEvent({ type: "StepBegin", payload: { n: 1 } }); + useChatStore.getState().processEvent({ + type: "ToolCall", + payload: { type: "function", id: "wf-1", function: { name: "DynamicWorkflow", arguments: "{}" } }, + }); +} + +function workflowToolItem(): UIStepItem & { type: "tool_use" } { + const last = useChatStore.getState().messages.at(-1)!; + const item = last.steps!.at(0)!.items.find((i) => i.type === "tool_use"); + return item as UIStepItem & { type: "tool_use" }; +} + +function wrap(agentId: string, event: { type: string; payload: unknown }, agentIndex?: number) { + return { + type: "SubagentEvent", + payload: { + parent_tool_call_id: "wf-1", + agent_id: agentId, + agent_label: "explore", + agent_index: agentIndex, + event, + }, + }; +} + +describe("Webview DynamicWorkflow per-agent lanes", () => { + it("keeps each subagent's streamed text in its own step when two agents interleave", () => { + startWorkflowTurn(); + + // Agent A opens its step, agent B opens its step, then their text arrives + // out of order. Each agent's text must land only in its own step. + useChatStore.getState().processEvent(wrap("agentA", { type: "StepBegin", payload: { n: 1 } }, 1)); + useChatStore.getState().processEvent(wrap("agentB", { type: "StepBegin", payload: { n: 1 } }, 2)); + useChatStore.getState().processEvent(wrap("agentA", { type: "ContentPart", payload: { type: "text", text: "hello from A" } }, 1)); + useChatStore.getState().processEvent(wrap("agentB", { type: "ContentPart", payload: { type: "text", text: "hello from B" } }, 2)); + useChatStore.getState().processEvent(wrap("agentA", { type: "ContentPart", payload: { type: "text", text: ", continued" } }, 1)); + + const toolItem = workflowToolItem(); + const textFor = (agentId: string) => + toolItem + .subagent_steps!.filter((s) => s.agentId === agentId) + .flatMap((s) => s.items) + .filter((i): i is { type: "text"; content: string } => i.type === "text") + .map((i) => i.content) + .join(""); + + expect(textFor("agentA")).toBe("hello from A, continued"); + expect(textFor("agentB")).toBe("hello from B"); + }); + + it("gives an agent exactly one step per StepBegin, with no leading empty step", () => { + startWorkflowTurn(); + + // A lane's step count drives its progress bar, so a seeded placeholder step + // in front of the agent's own StepBegin would inflate every lane by one. + useChatStore.getState().processEvent(wrap("agentA", { type: "StepBegin", payload: { n: 1 } }, 1)); + useChatStore.getState().processEvent(wrap("agentA", { type: "ContentPart", payload: { type: "text", text: "work" } }, 1)); + useChatStore.getState().processEvent(wrap("agentB", { type: "StepBegin", payload: { n: 1 } }, 2)); + useChatStore.getState().processEvent(wrap("agentB", { type: "ContentPart", payload: { type: "text", text: "work" } }, 2)); + + const steps = workflowToolItem().subagent_steps!; + expect(steps.filter((s) => s.agentId === "agentA")).toHaveLength(1); + expect(steps.filter((s) => s.agentId === "agentB")).toHaveLength(1); + expect(steps.every((s) => s.items.length > 0)).toBe(true); + }); + + it("stamps startedAt/endedAt as a lane's SubagentStatus transitions", () => { + startWorkflowTurn(); + + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", agent_label: "explore", agent_index: 1, status: "spawned" }, + }); + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", status: "running" }, + }); + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", status: "done", result_summary: "Explored 3 files" }, + }); + + const status = workflowToolItem().subagent_status!["agentA"]!; + expect(status.status).toBe("done"); + expect(status.resultSummary).toBe("Explored 3 files"); + expect(typeof status.startedAt).toBe("number"); + expect(typeof status.endedAt).toBe("number"); + }); + + it("targets the main agent's steps by array tail when no agent is scoped", () => { + // The only remaining no-agent case: applyEventToSteps called directly for + // the main agent's own turn, not through a SubagentEvent wrapper. + useChatStore.getState().processEvent({ type: "TurnBegin", payload: { user_input: "hello" } }); + useChatStore.getState().processEvent({ type: "StepBegin", payload: { n: 1 } }); + useChatStore.getState().processEvent({ type: "ContentPart", payload: { type: "text", text: "first" } }); + useChatStore.getState().processEvent({ type: "StepBegin", payload: { n: 2 } }); + useChatStore.getState().processEvent({ type: "ContentPart", payload: { type: "text", text: "second" } }); + + const last = useChatStore.getState().messages.at(-1)!; + expect(last.steps!.every((s) => s.agentId === undefined)).toBe(true); + const texts = last.steps! + .flatMap((s) => s.items) + .filter((i): i is { type: "text"; content: string } => i.type === "text") + .map((i) => i.content); + expect(texts).toEqual(["first", "second"]); + }); + + it("does not drop a second agent's content that arrives before that agent's own StepBegin", () => { + startWorkflowTurn(); + + // Agent A is already streaming; agent B's very first event is content, + // arriving before B has emitted its own StepBegin. + useChatStore.getState().processEvent(wrap("agentA", { type: "StepBegin", payload: { n: 1 } }, 1)); + useChatStore.getState().processEvent(wrap("agentA", { type: "ContentPart", payload: { type: "text", text: "from A" } }, 1)); + useChatStore.getState().processEvent(wrap("agentB", { type: "ContentPart", payload: { type: "text", text: "from B" } }, 2)); + + const toolItem = workflowToolItem(); + const textFor = (agentId: string) => + toolItem + .subagent_steps!.filter((s) => s.agentId === agentId) + .flatMap((s) => s.items) + .filter((i): i is { type: "text"; content: string } => i.type === "text") + .map((i) => i.content) + .join(""); + + expect(textFor("agentB")).toBe("from B"); + expect(textFor("agentA")).toBe("from A"); + }); + + it("marks lanes still spawned/running as failed when the batch's parent ToolResult lands", () => { + startWorkflowTurn(); + + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", agent_label: "explore", agent_index: 1, status: "spawned" }, + }); + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", status: "running" }, + }); + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentB", agent_label: "explore", agent_index: 2, status: "spawned" }, + }); + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentC", agent_label: "explore", agent_index: 3, status: "done" }, + }); + + // The batch is aborted: the parent tool call resolves while agentA (running) + // and agentB (spawned) never received a terminal lifecycle event of their own. + useChatStore.getState().processEvent({ + type: "ToolResult", + payload: { tool_call_id: "wf-1", return_value: { is_error: true, output: "aborted", message: "", display: [] } }, + }); + + const status = workflowToolItem().subagent_status!; + expect(status["agentA"]!.status).toBe("failed"); + expect(typeof status["agentA"]!.endedAt).toBe("number"); + expect(status["agentB"]!.status).toBe("failed"); + expect(typeof status["agentB"]!.endedAt).toBe("number"); + // Already-terminal lanes are left alone. + expect(status["agentC"]!.status).toBe("done"); + }); + + it("leaves spawned/running lanes alone when the parent ToolResult succeeds", () => { + startWorkflowTurn(); + + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", agent_label: "explore", agent_index: 1, status: "spawned" }, + }); + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", status: "running" }, + }); + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentB", status: "spawned" }, + }); + + useChatStore.getState().processEvent({ + type: "ToolResult", + payload: { tool_call_id: "wf-1", return_value: { is_error: false, output: "ok", message: "", display: [] } }, + }); + + expect(workflowToolItem().result).toMatchObject({ is_error: false }); + const status = workflowToolItem().subagent_status!; + expect(status["agentA"]!.status).toBe("running"); + expect(status["agentA"]!.endedAt).toBeUndefined(); + expect(status["agentB"]!.status).toBe("spawned"); + expect(status["agentB"]!.endedAt).toBeUndefined(); + }); +}); + +describe("workflow lane derivation", () => { + it("groups steps by agent, orders lanes by agentIndex, and sizes the bar to the busiest lane", () => { + const steps = [ + { n: 1, items: [], agentId: "b", agentLabel: "explore", agentIndex: 2 }, + { n: 1, items: [], agentId: "a", agentLabel: "explore", agentIndex: 1 }, + { n: 2, items: [], agentId: "a", agentLabel: "explore", agentIndex: 1 }, + ]; + const statuses = { + a: { status: "done" as const }, + b: { status: "running" as const }, + c: { status: "spawned" as const, label: "explore", index: 3 }, + }; + + const lanes = deriveWorkflowLanes(steps, statuses); + + expect(lanes.map((l) => l.agentId)).toEqual(["a", "b", "c"]); + expect(lanes.map((l) => l.stepCount)).toEqual([2, 1, 0]); + expect(maxLaneStepCount(lanes)).toBe(2); + }); +}); diff --git a/apps/vscode/test/publish-retry.test.ts b/apps/vscode/test/publish-retry.test.ts new file mode 100644 index 00000000..c34f65e6 --- /dev/null +++ b/apps/vscode/test/publish-retry.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; + +// @ts-expect-error -- plain .mjs build script, no type declarations +import { classifyError, publishEachTarget, withRetry } from '../scripts/publish-retry.mjs'; + +const TARGETS = ['darwin-x64', 'darwin-arm64', 'linux-x64']; +const FILES = TARGETS.map((target) => `/tmp/${target}.vsix`); + +// Backoff is real time; every retry test overrides it so the suite stays fast. +const FAST = { backoffMs: [0, 0] }; + +describe('classifyError', () => { + it('separates the three failure kinds that need different handling', () => { + // The exact string the Marketplace returned mid-publish on 2026-08-05. + expect(classifyError(new Error('Request timeout: /_apis/gallery/publishers/example'))).toBe('transient'); + expect(classifyError(new Error('connect ECONNRESET 13.107.42.16:443'))).toBe('transient'); + expect(classifyError(new Error('Response code 503 (Service Unavailable)'))).toBe('transient'); + + // The exact string the Entra credential path returned. + expect(classifyError(new Error('{"message":"The requested operation is not allowed."}'))).toBe('auth'); + expect(classifyError(new Error('Response code 401 (Unauthorized)'))).toBe('auth'); + + expect(classifyError(new Error('Extension entrypoint(s) missing'))).toBe('fatal'); + }); +}); + +describe('withRetry', () => { + it('retries a transient failure and returns the eventual success', async () => { + const action = vi + .fn() + .mockRejectedValueOnce(new Error('Request timeout')) + .mockResolvedValueOnce('published'); + + await expect(withRetry(action, { label: 'test', ...FAST })).resolves.toBe('published'); + expect(action).toHaveBeenCalledTimes(2); + }); + + it('gives up after the attempt budget and rethrows the last error', async () => { + const action = vi.fn().mockRejectedValue(new Error('Request timeout')); + + await expect(withRetry(action, { label: 'test', attempts: 3, ...FAST })).rejects.toThrow('Request timeout'); + expect(action).toHaveBeenCalledTimes(3); + }); + + it('does not retry a non-transient failure', async () => { + const action = vi.fn().mockRejectedValue(new Error('Response code 401 (Unauthorized)')); + + await expect(withRetry(action, { label: 'test', ...FAST })).rejects.toThrow('401'); + expect(action).toHaveBeenCalledTimes(1); + }); +}); + +describe('publishEachTarget', () => { + it('keeps publishing after one target fails, so a flake cannot strand the rest', async () => { + const publishOne = vi.fn(async (_file: string, target: string) => { + if (target === 'darwin-arm64') throw new Error('Extension rejected'); + return 'published'; + }); + + await expect( + publishEachTarget({ targets: TARGETS, files: FILES, registry: 'Marketplace', publishOne }), + ).rejects.toThrow('1 of 3 target(s) failed'); + + // The point of the change: linux-x64 is attempted even though darwin-arm64 died. + expect(publishOne.mock.calls.map((call) => call[1])).toEqual(TARGETS); + }); + + it('stops immediately on an auth failure instead of hammering every target', async () => { + const publishOne = vi.fn().mockRejectedValue(new Error('Response code 401 (Unauthorized)')); + + await expect( + publishEachTarget({ targets: TARGETS, files: FILES, registry: 'Marketplace', publishOne }), + ).rejects.toThrow('3 of 3 target(s) failed'); + + expect(publishOne).toHaveBeenCalledTimes(1); + }); + + it('treats an already-published target as success, so a re-run completes', async () => { + const publishOne = vi.fn(async (_file: string, target: string) => + target === 'darwin-x64' ? 'skipped' : 'published', + ); + + const result = await publishEachTarget({ + targets: TARGETS, + files: FILES, + registry: 'Marketplace', + publishOne, + }); + + expect(result).toEqual({ published: ['darwin-arm64', 'linux-x64'], skipped: ['darwin-x64'] }); + }); +}); diff --git a/apps/vscode/test/pythinker-harness.integration.test.ts b/apps/vscode/test/pythinker-harness.integration.test.ts index 5d1ddafd..61677746 100644 --- a/apps/vscode/test/pythinker-harness.integration.test.ts +++ b/apps/vscode/test/pythinker-harness.integration.test.ts @@ -355,32 +355,82 @@ async function runSlash( raw: string, ctx = {} as HandlerContext, ): Promise { - const command = parseHostSlashCommand(raw); + return (await startSlash(runtime, raw, ctx))(); +} + +/** Parses first and hands back the dispatch, for tests that assert on the busy flag. */ +async function startSlash( + runtime: SessionRuntime, + raw: string, + ctx = {} as HandlerContext, +): Promise<() => Promise> { + const command = await parseHostSlashCommand(raw, () => runtime.session.listSkills()); if (command === undefined) throw new Error(`Expected host slash command: ${raw}`); - return runHostSlashCommand(runtime, command, ctx); + return () => runHostSlashCommand(runtime, command, ctx); } describe("VS Code Pythinker harness integration (shares one in-process SDK home)", () => { - it("only intercepts released slash commands and user-invoked skills", () => { - expect(parseHostSlashCommand("/plan on")).toEqual({ name: "plan", args: "on", raw: "/plan on" }); - expect(parseHostSlashCommand(" /skill:review carefully ")).toEqual({ + it("only intercepts released slash commands and user-invoked skills", async () => { + await expect(parseHostSlashCommand("/plan on")).resolves.toEqual({ + name: "plan", + args: "on", + raw: "/plan on", + }); + await expect(parseHostSlashCommand(" /skill:review carefully ")).resolves.toEqual({ + name: "skill:review", + args: "carefully", + raw: "/skill:review carefully", + skillName: "review", + }); + await expect(parseHostSlashCommand("/not-a-host-command")).resolves.toBeUndefined(); + await expect(parseHostSlashCommand([{ type: "text", text: "/clear" }])).resolves.toBeUndefined(); + }); + + it("degrades to the skill prefix when the skill catalog fails", async () => { + // The parser runs on every message starting with "/", and its caller in + // chat.handler awaits it outside any try block — a rejection here silently + // drops the user's message instead of sending it. + const listSkills = () => Promise.reject(new Error("engine unavailable")); + + await expect(parseHostSlashCommand("/skill:review carefully", listSkills)).resolves.toEqual({ name: "skill:review", args: "carefully", raw: "/skill:review carefully", + skillName: "review", }); - expect(parseHostSlashCommand("/not-a-host-command")).toBeUndefined(); - expect(parseHostSlashCommand([{ type: "text", text: "/clear" }])).toBeUndefined(); + await expect(parseHostSlashCommand("/plan on", listSkills)).resolves.toEqual({ + name: "plan", + args: "on", + raw: "/plan on", + }); + await expect(parseHostSlashCommand("/unknown-thing", listSkills)).resolves.toBeUndefined(); + }); + + it("resolves a built-in skill invoked under its bare name", async () => { + const listSkills = async () => [ + { name: "gen-changesets", description: "", path: "/s", source: "builtin", type: "prompt" }, + ]; + + await expect( + parseHostSlashCommand("/gen-changesets", listSkills as never), + ).resolves.toMatchObject({ skillName: "gen-changesets" }); + await expect( + parseHostSlashCommand("/still-not-a-command", listSkills as never), + ).resolves.toBeUndefined(); }); - it("combines the released slash commands with user-activatable workspace skills", async () => { + it("combines the released slash commands with the session's user-activatable skills", async () => { const commands = await configHandlers[Methods.GetSlashCommands]!(undefined, { - workDir: "/workspace", - harness: { - listWorkspaceSkills: async () => [ - { name: "review", description: "Review changes", path: "/skills/review", source: "user", type: "prompt" }, - { name: "reference-only", description: "Reference", path: "/skills/ref", source: "user", type: "reference" }, - ], - }, + getSession: () => ({ + session: { + listSkills: async () => [ + { name: "review", description: "Review changes", path: "/skills/review", source: "user", type: "prompt" }, + { name: "reference-only", description: "Reference", path: "/skills/ref", source: "user", type: "reference" }, + { name: "model-only", description: "Model", path: "/skills/m", source: "user", type: "prompt", userInvocable: false }, + { name: "builtin-one", description: "Builtin", path: "/skills/b", source: "builtin", type: "prompt" }, + ], + }, + }), logError: () => undefined, } as unknown as HandlerContext); @@ -394,10 +444,20 @@ describe("VS Code Pythinker harness integration (shares one in-process SDK home) "add-dir", "export", "import", + "builtin-one", "skill:review", ]); }); + it("falls back to the released commands when no session is open yet", async () => { + const commands = await configHandlers[Methods.GetSlashCommands]!(undefined, { + getSession: () => undefined, + logError: () => undefined, + } as unknown as HandlerContext); + + expect((commands as Array<{ name: string }>).some((command) => command.name.startsWith("skill:"))).toBe(false); + }); + it("sends the package version in User-Agent when VS Code prompts the provider", async () => { const rig = await createRuntimeRig(); routeSuccessfulPrompt(rig.provider); @@ -1143,7 +1203,8 @@ describe("VS Code Pythinker harness integration (shares one in-process SDK home) await writeFile(join(rig.workDir, "prior.md"), "Enough prior context to compact."); await runSlash(runtime, "/import prior.md", streamChatContext(rig)); - const command = runSlash(runtime, "/compact keep decisions"); + const dispatch = await startSlash(runtime, "/compact keep decisions"); + const command = dispatch(); expect(runtime.isBusy).toBe(true); await expect(command).resolves.toBe(true); @@ -1155,37 +1216,55 @@ describe("VS Code Pythinker harness integration (shares one in-process SDK home) }); }); - it("keeps /yolo and /afk independent when they are combined", async () => { + it("moves between the permission modes as /yolo and /auto are used", async () => { const rig = await createRuntimeRig(); const runtime = await openRuntimeSession(rig); await runSlash(runtime, "/yolo"); - expect(runtime.legacyApprovalFlags).toEqual({ yolo: true, afk: false }); + expect(runtime.permissionMode).toBe("yolo"); await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); await runSlash(runtime, "/afk"); - expect(runtime.legacyApprovalFlags).toEqual({ yolo: true, afk: true }); + expect(runtime.permissionMode).toBe("auto"); await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "auto" }); await runSlash(runtime, "/afk"); - expect(runtime.legacyApprovalFlags).toEqual({ yolo: true, afk: false }); - await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); + expect(runtime.permissionMode).toBe("manual"); + await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "manual" }); }); - it("applies the global yolo setting when a closed VS Code session reopens", async () => { + it("accepts on and off arguments for the permission commands", async () => { + const rig = await createRuntimeRig(); + const runtime = await openRuntimeSession(rig); + + await runSlash(runtime, "/yolo on"); + expect(runtime.permissionMode).toBe("yolo"); + + await runSlash(runtime, "/yolo on"); + expect(runtime.permissionMode).toBe("yolo"); + + await runSlash(runtime, "/yolo off"); + expect(runtime.permissionMode).toBe("manual"); + }); + + it("keeps a /yolo session in yolo when it reopens with the setting off", async () => { const rig = await createRuntimeRig(); const first = await openRuntimeSession(rig); await runSlash(first, "/yolo"); await rig.runtime.detachView("view-1"); const reopened = await openRuntimeSession(rig, first.id); - expect(reopened.legacyApprovalFlags).toEqual({ yolo: false, afk: false }); - await expect(reopened.session.getStatus()).resolves.toMatchObject({ permission: "manual" }); - await rig.runtime.detachView("view-1"); - const yoloReopened = await openRuntimeSession(rig, first.id, true); - expect(yoloReopened.legacyApprovalFlags).toEqual({ yolo: true, afk: false }); - await expect(yoloReopened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); + expect(reopened.permissionMode).toBe("yolo"); + await expect(reopened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); + }); + + it("seeds a session that never chose a mode from the global yolo setting", async () => { + const rig = await createRuntimeRig(); + const first = await openRuntimeSession(rig, undefined, true); + + expect(first.permissionMode).toBe("yolo"); + await expect(first.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); }); it("exports current context as Markdown under the workspace", async () => { diff --git a/apps/vscode/test/pythinker-runtime.test.ts b/apps/vscode/test/pythinker-runtime.test.ts index 01505f90..30567e79 100644 --- a/apps/vscode/test/pythinker-runtime.test.ts +++ b/apps/vscode/test/pythinker-runtime.test.ts @@ -113,9 +113,12 @@ function createFakeSession( setPermissions.push(permission); status = { ...status, permission }; }, - async updateMetadata(patch: JsonObject) { - metadataUpdates.push(patch); - summary = { ...summary, metadata: { ...summary.metadata, ...patch } }; + async getSessionMetadata() { + return { custom: summary.metadata }; + }, + async updateSessionMetadata(patch: { custom?: JsonObject }) { + metadataUpdates.push(patch.custom ?? {}); + summary = { ...summary, metadata: patch.custom }; }, async close() { closes += 1; @@ -261,7 +264,7 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => { model: "kimi-k2", thinking: "high", permission: "yolo", - metadata: { vscode_legacy_approval: { yolo: true, afk: false } }, + metadata: { vscode_permission_mode: "yolo" }, }, ]); expect(opened.subscribers).toEqual(["view-1"]); @@ -396,91 +399,95 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => { }); }); - it("uses the yolo setting as the initial value for an unmarked resumed session", async () => { + it("seeds an unmarked resumed session from the yolo setting", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession("saved-1", "/workspace", { permission: "manual" }); await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true })); - expect(session.metadataUpdates).toEqual([ - { vscode_legacy_approval: { yolo: true, afk: false } }, - ]); + expect(session.metadataUpdates).toEqual([{ vscode_permission_mode: "yolo" }]); }); - it("lets the global yolo setting override a persisted off flag on resume", async () => { + it("keeps a stored yolo mode when the global setting is off", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession( "saved-1", "/workspace", { permission: "manual" }, - { vscode_legacy_approval: { yolo: false, afk: false } }, + { vscode_permission_mode: "yolo" }, ); - const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true })); + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false })); expect(session.setPermissions).toEqual(["yolo"]); - expect(session.metadataUpdates).toEqual([ - { vscode_legacy_approval: { yolo: true, afk: false } }, - ]); - expect(opened.legacyApprovalFlags).toEqual({ yolo: true, afk: false }); + expect(session.metadataUpdates).toEqual([]); + expect(opened.permissionMode).toBe("yolo"); }); - it("lets the global yolo setting disable a persisted session yolo flag on resume", async () => { + it("keeps a stored manual mode when the global setting is on", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession( "saved-1", "/workspace", - { permission: "yolo" }, - { vscode_legacy_approval: { yolo: true, afk: false } }, + { permission: "manual" }, + { vscode_permission_mode: "manual" }, ); - const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false })); + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true })); - expect(session.setPermissions).toEqual(["manual"]); - expect(session.metadataUpdates).toEqual([ - { vscode_legacy_approval: { yolo: false, afk: false } }, - ]); - expect(opened.legacyApprovalFlags).toEqual({ yolo: false, afk: false }); + expect(session.setPermissions).toEqual([]); + expect(session.metadataUpdates).toEqual([]); + expect(opened.permissionMode).toBe("manual"); }); - it("keeps the persisted afk flag while applying the global yolo setting on resume", async () => { + it("restores a stored auto mode", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession( "saved-1", "/workspace", { permission: "manual" }, - { vscode_legacy_approval: { yolo: false, afk: true } }, + { vscode_permission_mode: "auto" }, ); - const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true })); + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false })); expect(session.setPermissions).toEqual(["auto"]); - expect(opened.legacyApprovalFlags).toEqual({ yolo: true, afk: true }); + expect(opened.permissionMode).toBe("auto"); }); - it("restores persisted afk with core auto permission", async () => { + it("ignores an unrecognised stored mode and falls back to the setting", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession( "saved-1", "/workspace", { permission: "manual" }, - { vscode_legacy_approval: { yolo: false, afk: true } }, + { vscode_permission_mode: "nonsense" }, ); - await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false })); + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true })); - expect(session.setPermissions).toEqual(["auto"]); + expect(opened.permissionMode).toBe("yolo"); + expect(session.metadataUpdates).toEqual([{ vscode_permission_mode: "yolo" }]); }); - it("changes the setting-backed yolo flag without clearing session afk", async () => { + it("applies an explicit settings change to the live sessions", async () => { const { runtime } = createRuntime(); const opened = await runtime.openSession(openOptions()); - await opened.toggleLegacyApproval("afk"); - await runtime.setYoloModeForActiveSessions(true); + await runtime.setPermissionModeForActiveSessions("yolo"); + + expect(opened.permissionMode).toBe("yolo"); + await expect(opened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); + }); + + it("persists a mode change so the next attach restores it", async () => { + const { runtime, sdk } = createRuntime(); + const session = sdk.addSession("saved-1", "/workspace", { permission: "manual" }); + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false })); + + await opened.setPermissionMode("yolo"); - expect(opened.legacyApprovalFlags).toEqual({ yolo: true, afk: true }); - await expect(opened.session.getStatus()).resolves.toMatchObject({ permission: "auto" }); + expect(session.metadataUpdates.at(-1)).toEqual({ vscode_permission_mode: "yolo" }); }); it("keeps a shared session open when one of its Webviews detaches", async () => { diff --git a/apps/vscode/test/replay-adapter.test.ts b/apps/vscode/test/replay-adapter.test.ts index 745002d8..3bc2bae4 100644 --- a/apps/vscode/test/replay-adapter.test.ts +++ b/apps/vscode/test/replay-adapter.test.ts @@ -555,6 +555,7 @@ describe("replay adapter (renders the public SDK resume state for the Webview)", type: "SubagentEvent", payload: { parent_tool_call_id: "agent-call-1", + agent_id: "sub-1", event: { type: "ContentPart", payload: { type: "text", text: "first child answer" } }, }, })); @@ -562,6 +563,7 @@ describe("replay adapter (renders the public SDK resume state for the Webview)", type: "SubagentEvent", payload: { parent_tool_call_id: "agent-call-2", + agent_id: "sub-1", event: { type: "ContentPart", payload: { type: "text", text: "second child answer" } }, }, })); diff --git a/apps/vscode/test/replay-resume.integration.test.ts b/apps/vscode/test/replay-resume.integration.test.ts index a7f99c46..6d894990 100644 --- a/apps/vscode/test/replay-resume.integration.test.ts +++ b/apps/vscode/test/replay-resume.integration.test.ts @@ -298,6 +298,7 @@ describe("VS Code replay from a public Node SDK resume state", () => { type: "SubagentEvent", payload: { parent_tool_call_id: "agent-call-1", + agent_id: "agent-0", event: { type: "StepBegin", payload: { n: 1 } }, }, }), @@ -307,6 +308,7 @@ describe("VS Code replay from a public Node SDK resume state", () => { type: "SubagentEvent", payload: { parent_tool_call_id: "agent-call-1", + agent_id: "agent-0", event: { type: "ContentPart", payload: { type: "text", text: childAnswer } }, }, }), diff --git a/apps/vscode/test/session-runtime.test.ts b/apps/vscode/test/session-runtime.test.ts index 3c476553..deec905e 100644 --- a/apps/vscode/test/session-runtime.test.ts +++ b/apps/vscode/test/session-runtime.test.ts @@ -20,7 +20,6 @@ import type { import { describe, expect, it } from "vitest"; import { Events } from "../shared/bridge"; -import type { LegacyApprovalFlags } from "../src/runtime/legacy-approval"; import { SessionRuntime } from "../src/runtime/session-runtime"; interface BroadcastRecord { @@ -53,7 +52,7 @@ interface FakeSessionBoundary { requestQuestion(request: QuestionRequest): Promise>>; } -const DEFAULT_LEGACY_APPROVAL: LegacyApprovalFlags = { yolo: false, afk: false }; +const DEFAULT_PERMISSION_MODE: PermissionMode = "manual"; function createFakeSession(): FakeSessionBoundary { const listeners = new Set<(event: Event) => void>(); @@ -129,13 +128,16 @@ function createFakeSession(): FakeSessionBoundary { permission = mode; setPermissions.push(mode); }, - async updateMetadata(patch: JsonObject) { + async getSessionMetadata() { + return { custom: summary.metadata }; + }, + async updateSessionMetadata(patch: { custom?: JsonObject }) { if (nextMetadataError !== undefined) { const error = nextMetadataError; nextMetadataError = undefined; throw error; } - metadataUpdates.push(patch); + metadataUpdates.push(patch.custom ?? {}); }, async close() { closes += 1; @@ -173,13 +175,13 @@ function createFakeSession(): FakeSessionBoundary { }; } -function createRuntime(legacyApproval = DEFAULT_LEGACY_APPROVAL) { +function createRuntime(permissionMode = DEFAULT_PERMISSION_MODE) { const sdk = createFakeSession(); const broadcasts: BroadcastRecord[] = []; const baselines: BaselineRecord[] = []; const runtime = new SessionRuntime({ session: sdk.session, - legacyApproval, + permissionMode, broadcast: (event, data, webviewId) => broadcasts.push({ event, data, webviewId }), captureBaseline: (session, filePath, webviewIds) => { baselines.push({ session, filePath, webviewIds }); @@ -570,8 +572,8 @@ describe("session runtime (adapts one SDK session for subscribed Webviews)", () await expect(pending).resolves.toEqual(expected); }); - it("forwards SDK approval requests to the Webview in legacy yolo mode", async () => { - const { runtime, sdk, broadcasts } = createRuntime({ yolo: true, afk: false }); + it("forwards SDK approval requests to the Webview in yolo mode", async () => { + const { runtime, sdk, broadcasts } = createRuntime("yolo"); const pending = sdk.requestApproval({ toolCallId: "tool-yolo", toolName: "Bash", @@ -590,13 +592,31 @@ describe("session runtime (adapts one SDK session for subscribed Webviews)", () await expect(pending).resolves.toEqual({ decision: "approved" }); }); - it("switches core permission when a legacy approval flag is toggled", async () => { + it("switches core permission when the mode is toggled on", async () => { const { runtime, sdk } = createRuntime(); - await runtime.toggleLegacyApproval("afk"); + await expect(runtime.togglePermissionMode("auto")).resolves.toBe("auto"); expect(sdk.setPermissions).toEqual(["auto"]); - expect(runtime.legacyApprovalFlags).toEqual({ yolo: false, afk: true }); + expect(runtime.permissionMode).toBe("auto"); + }); + + it("returns to manual when the same mode is toggled again", async () => { + const { runtime, sdk } = createRuntime(); + + await runtime.togglePermissionMode("auto"); + await expect(runtime.togglePermissionMode("auto")).resolves.toBe("manual"); + + expect(sdk.setPermissions).toEqual(["auto", "manual"]); + expect(runtime.permissionMode).toBe("manual"); + }); + + it("persists each mode change into the session metadata", async () => { + const { runtime, sdk } = createRuntime(); + + await runtime.setPermissionMode("yolo"); + + expect(sdk.metadataUpdates.at(-1)).toMatchObject({ vscode_permission_mode: "yolo" }); }); it("resolves an SDK question when the Webview submits answers", async () => { @@ -624,8 +644,8 @@ describe("session runtime (adapts one SDK session for subscribed Webviews)", () await expect(pending).resolves.toEqual({ answers: { "Choose a target": "Tests" } }); }); - it("keeps SDK questions interactive in legacy yolo mode", async () => { - const { runtime, sdk, broadcasts } = createRuntime({ yolo: true, afk: false }); + it("keeps SDK questions interactive in yolo mode", async () => { + const { runtime, sdk, broadcasts } = createRuntime("yolo"); const pending = sdk.requestQuestion({ toolCallId: "question-yolo", questions: [ diff --git a/apps/vscode/test/settings-store.test.ts b/apps/vscode/test/settings-store.test.ts index bf4d07bb..c2da2778 100644 --- a/apps/vscode/test/settings-store.test.ts +++ b/apps/vscode/test/settings-store.test.ts @@ -39,16 +39,16 @@ import { import { useChatStore } from "../webview-ui/src/stores/chat.store"; const MODELS = [ - { id: "plain", name: "Plain", provider: "managed:pythinker-code", capabilities: [] }, + { id: "plain", name: "Plain", provider: "managed:kimi-code", capabilities: [] }, { id: "reasoning", name: "Reasoning", - provider: "managed:pythinker-code", + provider: "managed:kimi-code", capabilities: ["thinking"], support_efforts: ["low", "high"], default_effort: "high", }, - { id: "always", name: "Always", provider: "managed:pythinker-code", capabilities: ["always_thinking"] }, + { id: "always", name: "Always", provider: "managed:kimi-code", capabilities: ["always_thinking"] }, ]; beforeEach(() => { @@ -145,7 +145,7 @@ describe("Webview model settings persistence", () => { describe("Webview model metadata", () => { it("keeps same-named models in separate provider groups", () => { const groups = groupModelsByProvider([ - { id: "kimi/shared", name: "Shared", provider: "managed:pythinker-code", capabilities: [] }, + { id: "kimi/shared", name: "Shared", provider: "managed:kimi-code", capabilities: [] }, { id: "proxy/shared", name: "Shared", provider: "company-proxy", capabilities: [] }, ]); @@ -155,7 +155,7 @@ describe("Webview model metadata", () => { models: group.models.map((model) => model.id), }))).toEqual([ { provider: "company-proxy", label: "company-proxy", models: ["proxy/shared"] }, - { provider: "managed:pythinker-code", label: "Pythinker Code", models: ["kimi/shared"] }, + { provider: "managed:kimi-code", label: "Kimi Code", models: ["kimi/shared"] }, ]); }); @@ -192,7 +192,7 @@ describe("Webview model metadata", () => { it("requires Kimi login when the default model uses the managed provider", () => { expect(requiresManagedProviderLogin([ - { id: "kimi/model", name: "Kimi", provider: "managed:pythinker-code", capabilities: [] }, + { id: "kimi/model", name: "Kimi", provider: "managed:kimi-code", capabilities: [] }, ], "kimi/model", false)).toBe(true); }); }); diff --git a/apps/vscode/test/slash-menu.test.ts b/apps/vscode/test/slash-menu.test.ts new file mode 100644 index 00000000..2c85445f --- /dev/null +++ b/apps/vscode/test/slash-menu.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + NO_MATCH, + rankSlashCommands, + scoreCommand, +} from "../webview-ui/src/components/inputarea/hooks/slash-command-match"; + +function command(name: string, description = ""): { name: string; description: string; aliases: string[] } { + return { name, description, aliases: [] }; +} + +const RESEARCH_SKILL = command("skill:research-writing", "Write research reports."); +const CUSTOM_THEME = command( + "custom-theme", + // Its letters spell "research" in order, which is exactly what the old + // subsequence match over descriptions matched on. + "Create or edit a pythinker-code custom color theme — a JSON file of color tokens, then reload the chat.", +); + +function rank(commands: readonly ReturnType[], query: string): string[] { + return rankSlashCommands(commands, query).map((entry) => entry.name); +} + +describe("scoreCommand", () => { + it("does not match a command whose description merely contains the query's letters", () => { + // The old subsequence match over descriptions let "research" through on + // "Create or edit ...", which made the menu look unfiltered. + expect(scoreCommand(CUSTOM_THEME, "research")).toBe(NO_MATCH); + }); + + it("finds a namespaced skill by the part the user actually types", () => { + expect(rank([CUSTOM_THEME, RESEARCH_SKILL], "research")).toEqual(["skill:research-writing"]); + }); + + it("ranks a name prefix above a name that only contains the query", () => { + expect(rank([command("sub-skill"), command("skills")], "skill")).toEqual(["skills", "sub-skill"]); + }); + + it("stays forgiving about dropped separators and skipped letters", () => { + expect(Number.isFinite(scoreCommand(RESEARCH_SKILL, "researchwriting"))).toBe(true); + expect(Number.isFinite(scoreCommand(RESEARCH_SKILL, "reswrit"))).toBe(true); + }); + + it("never matches on the description", () => { + // "/sk" used to reach "/yolo" because its description contains those + // letters, so the highlight sat on an unrelated command. + const yolo = command("yolo", "Skip every approval prompt for this session."); + expect(scoreCommand(yolo, "sk")).toBe(NO_MATCH); + expect(rank([yolo, command("sub-skill"), command("invoke-skill")], "sk")).toEqual([ + "sub-skill", + "invoke-skill", + ]); + expect(scoreCommand(CUSTOM_THEME, "color")).toBe(NO_MATCH); + }); +}); diff --git a/apps/vscode/test/workspace-paths.test.ts b/apps/vscode/test/workspace-paths.test.ts index 4cdbcd8b..f1582b7d 100644 --- a/apps/vscode/test/workspace-paths.test.ts +++ b/apps/vscode/test/workspace-paths.test.ts @@ -305,7 +305,7 @@ describe("Webview workspace paths (selected-directory containment)", () => { } as unknown as Session; const runtime = new SessionRuntime({ session, - legacyApproval: { yolo: false, afk: false }, + permissionMode: "manual", broadcast: vi.fn(), captureBaseline: (summary, filePath, webviewIds) => { bridge.captureFileBaseline(summary, filePath, webviewIds); diff --git a/apps/vscode/tsconfig.json b/apps/vscode/tsconfig.json index a770d3a7..65ff60c2 100644 --- a/apps/vscode/tsconfig.json +++ b/apps/vscode/tsconfig.json @@ -15,5 +15,12 @@ } }, "include": ["src/**/*", "shared/**/*", "test/**/*"], - "exclude": ["dist", "node_modules", "webview-ui", "test/settings-store.test.ts", "test/app-init.test.ts"] + "exclude": [ + "dist", + "node_modules", + "webview-ui", + "test/settings-store.test.ts", + "test/app-init.test.ts", + "test/event-handlers.test.ts" + ] } diff --git a/apps/vscode/webview-ui/public/pythinker-logo.png b/apps/vscode/webview-ui/public/pythinker-logo.png index 865d7529..4fdf646b 100644 Binary files a/apps/vscode/webview-ui/public/pythinker-logo.png and b/apps/vscode/webview-ui/public/pythinker-logo.png differ diff --git a/apps/vscode/webview-ui/public/pythinker_animated.svg b/apps/vscode/webview-ui/public/pythinker_animated.svg new file mode 100644 index 00000000..bf23b5bc --- /dev/null +++ b/apps/vscode/webview-ui/public/pythinker_animated.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx index 2495bc2c..f066d5f0 100644 --- a/apps/vscode/webview-ui/src/App.tsx +++ b/apps/vscode/webview-ui/src/App.tsx @@ -4,6 +4,7 @@ import { Header } from "./components/Header"; import { ChatArea } from "./components/ChatArea"; import { InputArea } from "./components/inputarea/InputArea"; import { MCPServersModal } from "./components/MCPServersModal"; +import { ProvidersModal } from "./components/ProvidersModal"; import { WorkDirModal } from "./components/WorkDirModal"; import { SettingsDialog } from "./components/SettingsDialog"; import { ConfigErrorScreen } from "./components/ConfigErrorScreen"; @@ -18,7 +19,7 @@ import "./styles/index.css"; function MainContent({ onAuthAction }: { onAuthAction: () => void }) { const { processEvent, startNewConversation, sessionId } = useChatStore(); - const { setMCPServers, setExtensionConfig, extensionConfig } = useSettingsStore(); + const { setMCPServers, setExtensionConfig, extensionConfig, setWireSlashCommands } = useSettingsStore(); useEffect(() => { return bridge.on(Events.StreamEvent, (event: UIStreamEvent) => { @@ -40,6 +41,7 @@ function MainContent({ onAuthAction }: { onAuthAction: () => void }) { useEffect(() => { const unsubs = [ bridge.on(Events.MCPServersChanged, setMCPServers), + bridge.on(Events.SlashCommandsChanged, setWireSlashCommands), bridge.on(Events.ExtensionConfigChanged, ({ config }: { config: ExtensionConfig }) => setExtensionConfig(config)), bridge.on(Events.FocusInput, () => document.querySelector("textarea")?.focus()), bridge.on(Events.NewConversation, () => { @@ -49,7 +51,7 @@ function MainContent({ onAuthAction }: { onAuthAction: () => void }) { }), ]; return () => unsubs.forEach((u) => u()); - }, [setMCPServers, setExtensionConfig, startNewConversation]); + }, [setMCPServers, setExtensionConfig, setWireSlashCommands, startNewConversation]); useEffect(() => { if (!extensionConfig.enableNewConversationShortcut) return; @@ -74,6 +76,7 @@ function MainContent({ onAuthAction }: { onAuthAction: () => void }) {
+ diff --git a/apps/vscode/webview-ui/src/components/ActionMenu.tsx b/apps/vscode/webview-ui/src/components/ActionMenu.tsx index ed9e58b3..23c02ae7 100644 --- a/apps/vscode/webview-ui/src/components/ActionMenu.tsx +++ b/apps/vscode/webview-ui/src/components/ActionMenu.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { IconSettings, IconServer, IconLogout, IconLogin, IconLoader2, IconRefresh, IconFileText, IconFolder } from "@tabler/icons-react"; +import { IconSettings, IconServer, IconPlug, IconLogout, IconLogin, IconLoader2, IconRefresh, IconFileText, IconFolder } from "@tabler/icons-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; @@ -44,13 +44,18 @@ function MenuItem({ onClick, disabled, danger, children }: { onClick: () => void export function ActionMenu({ className, onAuthAction }: ActionMenuProps) { const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); - const { setMCPModalOpen, isLoggedIn, setIsLoggedIn, extensionConfig } = useSettingsStore(); + const { setMCPModalOpen, setProvidersModalOpen, isLoggedIn, setIsLoggedIn, extensionConfig } = useSettingsStore(); const handleOpenSettings = () => { void bridge.openSettings(); setOpen(false); }; + const handleOpenProviders = () => { + setProvidersModalOpen(true); + setOpen(false); + }; + const handleOpenMCPServers = () => { setMCPModalOpen(true); setOpen(false); @@ -105,6 +110,10 @@ export function ActionMenu({ className, onAuthAction }: ActionMenuProps) { Working Directory + + + Model Providers + MCP Servers diff --git a/apps/vscode/webview-ui/src/components/ChatArea.tsx b/apps/vscode/webview-ui/src/components/ChatArea.tsx index 399848ac..5d4f8e88 100644 --- a/apps/vscode/webview-ui/src/components/ChatArea.tsx +++ b/apps/vscode/webview-ui/src/components/ChatArea.tsx @@ -15,7 +15,7 @@ function ScrollButton() { return ( diff --git a/apps/vscode/webview-ui/src/components/ChatMessage.tsx b/apps/vscode/webview-ui/src/components/ChatMessage.tsx index aaee8432..00373f8f 100644 --- a/apps/vscode/webview-ui/src/components/ChatMessage.tsx +++ b/apps/vscode/webview-ui/src/components/ChatMessage.tsx @@ -1,4 +1,4 @@ -import { useState, Fragment, memo } from "react"; +import { useState, Fragment, memo, type ReactNode } from "react"; import { IconGitFork, IconBolt } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { Content } from "@/lib/content"; @@ -33,7 +33,7 @@ function ThinkingIndicator() { return (
-
+
Pythinking @@ -45,9 +45,9 @@ function ThinkingIndicator() {
{speed > 0 && ( - - - {speed.toFixed(1)} t/s + + + {speed.toFixed(1)} t/s )}
@@ -72,7 +72,7 @@ function StepItemRenderer({ item }: { item: UIStepItem }) { case "text": return ; case "tool_use": - return ; + return ; case "compaction": return ; case "steer": @@ -82,10 +82,23 @@ function StepItemRenderer({ item }: { item: UIStepItem }) { } } -function StepContent({ step, showConnector }: { step: UIStep; showConnector?: boolean }) { +/** A reply row whose gutter carries the assistant logo, lined up with the step markers. */ +function LogoRow({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+
+ +
+
{children}
+
+ ); +} + +function StepContent({ step, showConnector, showLogo }:{ step: UIStep; showConnector?: boolean; showLogo?: boolean }) { const hasItems = step.items.length > 0; const hasToolOrThinking = step.items.some((item) => item.type === "tool_use" || item.type === "thinking" || item.type === "compaction"); const showIndicator = hasToolOrThinking; + const logoItemIndex = step.items.findIndex((item) => item.type === "text"); const hasActiveItem = step.items.some((item) => (item.type === "text" || item.type === "thinking") && !item.finished); if (!hasItems) { @@ -113,9 +126,20 @@ function StepContent({ step, showConnector }: { step: UIStep; showConnector?: bo
)}
- {step.items.map((item, idx) => ( - - ))} + {step.items.map((item, idx) => { + const renderer = ; + if (!showLogo || idx !== logoItemIndex) { + return {renderer}; + } + // The logo belongs beside the reply itself, not beside whatever tool or + // thinking block happens to open the step, so it claims the outer gutter + // from this row: `-ml-7` is the `w-5` marker column plus the `gap-2`. + return ( + + {renderer} + + ); + })}
); @@ -295,6 +319,11 @@ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMe {hasSteps && groupStepsByPlanMode(steps).map((group, gi) => { const totalSteps = steps.length; + // The logo identifies the assistant on its first reply, but only when the + // message draws no timeline: sitting in the gutter, it would cut the + // connector running between the step markers. + const hasTimeline = stepHasIndicator.filter(Boolean).length > 1; + const firstTextStepIndex = hasTimeline ? -1 : steps.findIndex((s) => s.items.some((item) => item.type === "text")); const stepsContent = group.steps.map((step, i) => { const globalIndex = group.startIndex + i; const isLastInGroup = i === group.steps.length - 1; @@ -302,7 +331,7 @@ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMe const hasIndicator = stepHasIndicator[globalIndex]; const hasNextIndicator = stepHasIndicator.slice(globalIndex + 1).some(Boolean); const showConnector = hasIndicator && hasNextIndicator && !isLastInGroup && !isLastOverall; - return ; + return ; }); if (group.planMode) { @@ -310,7 +339,11 @@ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMe } return {stepsContent}; })} - {!hasSteps && displayContent && } + {!hasSteps && displayContent && ( + + + + )} {(images.length > 0 || videos.length > 0) && (
diff --git a/apps/vscode/webview-ui/src/components/Markdown.tsx b/apps/vscode/webview-ui/src/components/Markdown.tsx index 12efe060..ab84e06d 100644 --- a/apps/vscode/webview-ui/src/components/Markdown.tsx +++ b/apps/vscode/webview-ui/src/components/Markdown.tsx @@ -253,7 +253,8 @@ export const Markdown = memo(function Markdown({ content, className, enableEnric if (isInline) { const showColor = enableEnrichment && hasColors(code); return ( - + // --brand reads as a distinct token without competing with body text. + {showColor ? : children} ); diff --git a/apps/vscode/webview-ui/src/components/ProvidersModal.tsx b/apps/vscode/webview-ui/src/components/ProvidersModal.tsx new file mode 100644 index 00000000..8c487cd0 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ProvidersModal.tsx @@ -0,0 +1,437 @@ +import { useEffect, useMemo, useState } from "react"; +import { + IconCheck, + IconChevronDown, + IconKey, + IconLoader2, + IconPlug, + IconPlus, + IconSearch, + IconTrash, + IconX, +} from "@tabler/icons-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { bridge } from "@/services"; +import { useSettingsStore } from "@/stores"; +import { cn } from "@/lib/utils"; +import type { + CatalogProviderSummary, + ConfiguredProvider, + ProvidersView, +} from "shared/types"; + +const KEY_SOURCE_LABEL: Record = { + config: "Key in config.toml", + env: "Key from environment", + oauth: "Signed in", + none: "No key configured", +}; + +export function ProvidersModal() { + const { providersModalOpen, setProvidersModalOpen } = useSettingsStore(); + const [view, setView] = useState({ providers: [], defaultModel: null }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const [addOpen, setAddOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(); + + useEffect(() => { + if (!providersModalOpen) return; + setLoading(true); + setError(undefined); + bridge + .getProviders() + .then(setView) + .catch((error: unknown) => setError(messageOf(error))) + .finally(() => setLoading(false)); + }, [providersModalOpen]); + + if (!providersModalOpen) return null; + + const remove = async (providerId: string) => { + setDeleteTarget(undefined); + setError(undefined); + try { + setView(await bridge.removeProvider(providerId)); + } catch (error) { + setError(messageOf(error)); + } + }; + + return ( + <> +
+
+
+ +

Model Providers

+
+
+ + +
+
+ +
+
+ {error && ( +
+ {error} +
+ )} + +

+ Providers live in ~/.pythinker-code/config.toml, the same file the + CLI reads. Anything added here works in the terminal too. +

+ + {loading && view.providers.length === 0 ? ( +
+ + Reading config.toml… +
+ ) : view.providers.length === 0 ? ( +
+

No providers configured yet.

+ +
+ ) : ( + view.providers.map((provider) => ( + setDeleteTarget(provider.id)} + /> + )) + )} +
+
+
+ + {addOpen && ( + setAddOpen(false)} + onAdded={(next) => { + setView(next); + setAddOpen(false); + }} + /> + )} + + !open && setDeleteTarget(undefined)}> + + + Remove {deleteTarget}? + + Its models disappear from the model picker, in the extension and in the CLI. The API key is deleted + from config.toml. + + + + Cancel + deleteTarget && void remove(deleteTarget)}>Remove + + + + + ); +} + +function ProviderRow({ + provider, + defaultModel, + onRemove, +}: { + provider: ConfiguredProvider; + defaultModel: string | null; + onRemove: () => void; +}) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+ + + + {provider.keySource === "env" ? provider.apiKeyEnvVar : KEY_SOURCE_LABEL[provider.keySource]} + + +
+ + {expanded && ( +
+ {provider.baseUrl && ( +
{provider.baseUrl}
+ )} + {provider.models.map((model) => ( +
+ {model === defaultModel && } + + {model} + +
+ ))} +
+ )} +
+ ); +} + +function AddProviderPanel({ + onClose, + onAdded, +}: { + onClose: () => void; + onAdded: (view: ProvidersView) => void; +}) { + const [catalog, setCatalog] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(); + const [apiKey, setApiKey] = useState(""); + const [useEnvVar, setUseEnvVar] = useState(false); + const [defaultModel, setDefaultModel] = useState(); + const [saving, setSaving] = useState(false); + + useEffect(() => { + bridge + .getProviderCatalog() + .then(setCatalog) + .catch((error: unknown) => setError(messageOf(error))) + .finally(() => setLoading(false)); + }, []); + + const matches = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) return catalog; + return catalog.filter( + (entry) => entry.name.toLowerCase().includes(needle) || entry.id.toLowerCase().includes(needle), + ); + }, [catalog, query]); + + const submit = async () => { + if (selected === undefined) return; + setSaving(true); + setError(undefined); + try { + onAdded( + await bridge.addCatalogProvider({ + providerId: selected.id, + apiKey: useEnvVar ? undefined : apiKey.trim(), + apiKeyEnvVar: useEnvVar ? selected.apiKeyEnvVar : undefined, + defaultModel, + }), + ); + } catch (error) { + setError(messageOf(error)); + } finally { + setSaving(false); + } + }; + + const canSubmit = selected !== undefined && (useEnvVar ? selected.apiKeyEnvVar !== undefined : apiKey.trim().length > 0); + + return ( +
+
+

{selected === undefined ? "Choose a provider" : `Connect ${selected.name}`}

+ +
+ +
+
+ {error && ( +
+ {error} +
+ )} + + {selected === undefined ? ( + <> +
+ + setQuery(event.target.value)} + placeholder="Search providers…" + className="h-7 text-xs pl-7" + /> +
+ {loading ? ( +
+ + Loading the models.dev catalog… +
+ ) : ( +
+ {matches.map((entry) => ( + + ))} + {matches.length === 0 && ( +

No provider matches “{query}”.

+ )} +
+ )} + + ) : ( + <> +
+ + {useEnvVar ? ( +

+ Read at connection time from{" "} + {selected.apiKeyEnvVar}. The key is never written to disk. +

+ ) : ( + setApiKey(event.target.value)} + placeholder="sk-…" + className="h-7 text-xs font-mono" + /> + )} + {selected.apiKeyEnvVar !== undefined && ( + + )} + {!useEnvVar && ( +

+ Stored in config.toml in plain text, as the CLI does. +

+ )} +
+ +
+ +
+ {selected.models.map((model) => ( + + ))} +
+

+ All {selected.models.length} models are imported either way. Leave this unset to keep your current + default. +

+
+ + )} +
+
+ + {selected !== undefined && ( +
+ + +
+ )} +
+ ); +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/vscode/webview-ui/src/components/SettingsDialog.tsx b/apps/vscode/webview-ui/src/components/SettingsDialog.tsx index e5f6c85a..96b27b1b 100644 --- a/apps/vscode/webview-ui/src/components/SettingsDialog.tsx +++ b/apps/vscode/webview-ui/src/components/SettingsDialog.tsx @@ -17,7 +17,7 @@ const SETTING_ROWS: SettingRow[] = [ { key: "yoloMode", label: "YOLO mode", - description: "Auto-approve regular tool calls; the agent may still ask questions.", + description: "Auto-approve regular tool calls; the agent may still ask questions. Also starts new sessions in this mode.", }, { key: "autosave", diff --git a/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx b/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx index 3a7d0028..374e4b4b 100644 --- a/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx +++ b/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx @@ -17,6 +17,20 @@ function highlightMatch(text: string, query: string): React.ReactNode { const lowerText = text.toLowerCase(); const lowerQuery = query.toLowerCase(); + + // The common case is a contiguous match; highlighting it as one run reads far + // better than scattering bold letters across the whole name. + const at = lowerText.indexOf(lowerQuery); + if (at !== -1) { + return ( + <> + {text.slice(0, at)} + {text.slice(at, at + query.length)} + {text.slice(at + query.length)} + + ); + } + const parts: React.ReactNode[] = []; let lastIdx = 0; let qi = 0; diff --git a/apps/vscode/webview-ui/src/components/ThinkingButton.tsx b/apps/vscode/webview-ui/src/components/ThinkingButton.tsx index 7e7c0c5a..236d53fc 100644 --- a/apps/vscode/webview-ui/src/components/ThinkingButton.tsx +++ b/apps/vscode/webview-ui/src/components/ThinkingButton.tsx @@ -33,8 +33,8 @@ export function ThinkingButton({ mode, effort, efforts = [], alwaysOn = false, d disabled={disabled || mode === "always"} className={cn( "flex items-center gap-0.5 justify-center h-6 min-w-6 px-1 rounded-md transition-all", - active ? "bg-primary/15 text-primary" : "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground", - !disabled && mode !== "always" && "cursor-pointer hover:bg-primary/25", + active ? "bg-blue-500/15 text-blue-500" : "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground", + !disabled && mode !== "always" && "cursor-pointer hover:bg-blue-500/25", (disabled || mode === "always") && "cursor-default", )} > diff --git a/apps/vscode/webview-ui/src/components/ToolRenderers.tsx b/apps/vscode/webview-ui/src/components/ToolRenderers.tsx index d46ef1c5..f7809666 100644 --- a/apps/vscode/webview-ui/src/components/ToolRenderers.tsx +++ b/apps/vscode/webview-ui/src/components/ToolRenderers.tsx @@ -14,12 +14,14 @@ import { IconSquareChevronRight, } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; +import { getToolLabel, parseArgs } from "@/lib/tool-args"; import { FileLink, Markdown } from "./Markdown"; import { DisplayBlocks } from "./DisplayBlocks"; import { formatContentOutput } from "shared/legacy-sdk"; import { cleanSystemTags } from "shared/utils"; import { ThinkingBlock } from "./ThinkingBlock"; -import type { UIToolCall, UIStep, UIStepItem } from "@/stores/chat.store"; +import { WorkflowCard } from "./WorkflowCard"; +import type { UIToolCall, UIStep, UIStepItem, UISubagentStatus } from "@/stores/chat.store"; import type { ToolResult, DisplayBlock, TodoBlock } from "shared/legacy-sdk"; type ToolResultValue = ToolResult["return_value"]; @@ -28,17 +30,7 @@ interface ToolRendererProps { call: UIToolCall; result?: ToolResultValue; subagentSteps?: UIStep[]; -} - -function parseArgs(args: string | null): Record { - if (!args) { - return {}; - } - try { - return JSON.parse(args); - } catch { - return { raw: args }; - } + subagentStatus?: Record; } function formatOutput(output: string | object | object[]): string { @@ -307,7 +299,7 @@ function GenericTool({ call, result }: ToolRendererProps) { ); } -function SubagentStepItemRenderer({ item }: { item: UIStepItem }) { +export function SubagentStepItemRenderer({ item }: { item: UIStepItem }) { if (item.type === "thinking") { return ; } @@ -315,7 +307,7 @@ function SubagentStepItemRenderer({ item }: { item: UIStepItem }) { return ; } if (item.type === "tool_use") { - return ; + return ; } return null; } @@ -385,32 +377,24 @@ function TaskTool({ call, result, subagentSteps }: ToolRendererProps) { ); } -function getToolLabel(call: UIToolCall): string { - const args = parseArgs(call.arguments); - switch (call.name) { - case "Shell": - return (args.command as string) || "command"; - case "ReadFile": - return (args.path as string)?.split("/").pop() || "file"; - case "WriteFile": - return (args.path as string)?.split("/").pop() || "file"; - case "StrReplaceFile": - return (args.path as string)?.split("/").pop() || "file"; - case "Glob": - return (args.pattern as string) || "pattern"; - case "Task": - return (args.description as string) || "subagent task"; - case "SetTodoList": - return "Update Todos"; - default: - return ""; - } -} - -export function ToolCallCard({ call, result, subagentSteps }: ToolRendererProps) { +export function ToolCallCard({ call, result, subagentSteps, subagentStatus }: ToolRendererProps) { const [expanded, setExpanded] = useState(false); const status = !result ? "pending" : !result.is_error ? "success" : "error"; + // DynamicWorkflow batches render as per-agent lanes instead of the generic + // escaped-JSON card: its header/progress semantics don't fit the shared shell. + if (call.name === "DynamicWorkflow") { + return ( + } + /> + ); + } + const renderContent = () => { const props = { call, result, subagentSteps }; switch (call.name) { diff --git a/apps/vscode/webview-ui/src/components/WorkflowCard.tsx b/apps/vscode/webview-ui/src/components/WorkflowCard.tsx new file mode 100644 index 00000000..9ee8b26b --- /dev/null +++ b/apps/vscode/webview-ui/src/components/WorkflowCard.tsx @@ -0,0 +1,139 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { deriveWorkflowLanes, maxLaneStepCount, type WorkflowLane } from "@/lib/workflow-lanes"; +import { getToolLabel, parseArgs } from "@/lib/tool-args"; +import type { UIToolCall, UIStep, UIStepItem, UISubagentStatus } from "@/stores/chat.store"; +import type { ToolResult } from "shared/legacy-sdk"; + +interface WorkflowCardProps { + call: UIToolCall; + result?: ToolResult["return_value"]; + subagentSteps: UIStep[]; + subagentStatus: Record; + /** Injected by ToolRenderers: tool rendering is mutually recursive, and importing + * it here would close an import cycle that oxlint rejects. */ + renderStepItem: (item: UIStepItem) => ReactNode; +} + +function formatDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.round(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, "0")}` : `${seconds}s`; +} + +function laneLabel(lane: WorkflowLane): string { + if (lane.label) return lane.index !== undefined ? `${lane.label}/${lane.index}` : lane.label; + return lane.agentId.slice(0, 8); +} + +function laneMostRecentToolLabel(lane: WorkflowLane): string | null { + for (let i = lane.steps.length - 1; i >= 0; i--) { + const items = lane.steps[i].items; + for (let j = items.length - 1; j >= 0; j--) { + const item = items[j]; + if (item.type === "tool_use") return getToolLabel(item.call); + } + } + return null; +} + +function StatusDot({ status }: { status: WorkflowLane["status"] }) { + const color = + status === "running" ? "bg-brand" : status === "done" ? "bg-success" : status === "failed" ? "bg-destructive" : "bg-muted-foreground"; + return ; +} + +function LaneBar({ fraction, done }: { fraction: number; done: boolean }) { + return ( +
+
+
+ ); +} + +function LaneRow({ lane, maxSteps, renderStepItem }: { lane: WorkflowLane; maxSteps: number; renderStepItem: (item: UIStepItem) => ReactNode }) { + const [expanded, setExpanded] = useState(false); + const done = lane.status === "done"; + // A finished lane always reads full: the bar is relative to the busiest agent, + // so a lane that did fewer steps than the busiest one would otherwise show a + // gap after it completed. + const fraction = done ? 1 : maxSteps > 0 ? lane.stepCount / maxSteps : 0; + const queued = lane.status === "spawned" && lane.stepCount === 0; + const runningToolLabel = lane.status === "running" ? laneMostRecentToolLabel(lane) : null; + const duration = lane.startedAt !== undefined && lane.endedAt !== undefined ? formatDuration(lane.endedAt - lane.startedAt) : null; + + return ( +
+ + {lane.error &&
{lane.error}
} + {runningToolLabel &&
{runningToolLabel}
} + {expanded && ( +
+ {lane.steps.map((step) => ( +
+
Step {step.n}
+
+ {step.items.map((item, idx) => ( +
{renderStepItem(item)}
+ ))} +
+
+ ))} +
+ )} +
+ ); +} + +export function WorkflowCard({ call, result, subagentSteps, subagentStatus, renderStepItem }: WorkflowCardProps) { + const description = (parseArgs(call.arguments).description as string) || "Dynamic workflow"; + const lanes = useMemo( + () => deriveWorkflowLanes(subagentSteps, subagentStatus), + [subagentSteps, subagentStatus], + ); + const maxSteps = maxLaneStepCount(lanes); + const doneCount = lanes.filter((lane) => lane.status === "done").length; + const totalSteps = lanes.reduce((sum, lane) => sum + lane.stepCount, 0); + const showBatchBar = lanes.length > 1; + + return ( +
+
+
{description}
+
+ + {lanes.length} agent{lanes.length !== 1 ? "s" : ""} · {doneCount} done · {totalSteps} steps + + {showBatchBar && ( + <> +
+
0 ? Math.round((doneCount / lanes.length) * 100) : 0}%` }} /> +
+ + {doneCount}/{lanes.length} + + + )} +
+
+
+ {lanes.map((lane) => ( + + ))} +
+ {maxSteps > 0 &&
bar = steps relative to busiest agent ({maxSteps})
} + {result?.is_error &&
{typeof result.output === "string" ? result.output : "Workflow failed"}
} +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx index f2c65660..cf2e2f9a 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx +++ b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx @@ -182,9 +182,23 @@ export function InputArea({ onAuthAction }: InputAreaProps) { clearInput(); }); + const applyText = useMemoizedFn((newText: string, newCursorPos: number) => { + setText(newText); + setCursorPos(newCursorPos); + setTimeout(() => { + textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos); + textareaRef.current?.focus(); + adjustHeight(); + }, 0); + }); + const handleSlashCommand = useMemoizedFn((name: string) => { - sendMessage(`/${name}`); - clearInput(); + // Picking a command completes the token being typed. Sending it here would + // discard the rest of the message the command was being written into. + const before = activeToken ? text.slice(0, activeToken.start) : text; + const after = activeToken ? text.slice(cursorPos) : ""; + const insertion = `/${name} `; + applyText(`${before}${insertion}${after}`, before.length + insertion.length); }); const applyMention = useMemoizedFn((filePath: string) => { @@ -196,13 +210,7 @@ export function InputArea({ onAuthAction }: InputAreaProps) { isAppend: false, }); - setText(newText); - setCursorPos(newCursorPos); - setTimeout(() => { - textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos); - textareaRef.current?.focus(); - adjustHeight(); - }, 0); + applyText(newText, newCursorPos); }); const { diff --git a/apps/vscode/webview-ui/src/components/inputarea/hooks/slash-command-match.ts b/apps/vscode/webview-ui/src/components/inputarea/hooks/slash-command-match.ts new file mode 100644 index 00000000..a0c2e360 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/inputarea/hooks/slash-command-match.ts @@ -0,0 +1,66 @@ +import type { SlashCommandInfo } from "shared/legacy-sdk"; + +export const NO_MATCH = Number.POSITIVE_INFINITY; + +function isSubsequence(text: string, query: string): boolean { + let qi = 0; + for (let i = 0; i < text.length && qi < query.length; i++) { + if (text[i] === query[qi]) { + qi++; + } + } + return qi === query.length; +} + +/** Separators are noise while typing: `researchwriting` should still find `skill:research-writing`. */ +function letters(text: string): string { + return text.toLowerCase().replaceAll(/[^a-z0-9]/g, ""); +} + +function matchesAnyWordPrefix(text: string, query: string): boolean { + return text + .toLowerCase() + .split(/[^a-z0-9]+/) + .some((word) => word.startsWith(query)); +} + +/** + * Lower is a better match, `NO_MATCH` means the command is filtered out. + * + * Only the command name is matched. A description is prose — matching it pulls + * in commands that have nothing to do with what was typed (`/sk` reaching + * `/yolo` because its description happens to contain those letters), and the + * user cannot tell why they are listed. + */ +export function scoreCommand(command: SlashCommandInfo, query: string): number { + const name = command.name.toLowerCase(); + const q = query.toLowerCase(); + if (name.startsWith(q)) { + return 0; + } + // A namespaced skill (`skill:research-writing`) should also match on the part + // the user is actually thinking of, not only on its full prefix. + if (matchesAnyWordPrefix(name, q)) { + return 1; + } + if (name.includes(q)) { + return 2; + } + // Forgiving tier: skipped letters and dropped separators still match. + return isSubsequence(letters(name), letters(q)) ? 3 : NO_MATCH; +} + +/** Commands that match `query`, best match first. An empty query keeps the original order. */ +export function rankSlashCommands( + commands: readonly SlashCommandInfo[], + query: string, +): SlashCommandInfo[] { + if (!query) { + return [...commands]; + } + return commands + .map((command) => ({ command, score: scoreCommand(command, query) })) + .filter((entry) => entry.score !== NO_MATCH) + .toSorted((left, right) => left.score - right.score) + .map((entry) => entry.command); +} diff --git a/apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts b/apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts index c5661974..39324b1a 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts +++ b/apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts @@ -1,6 +1,7 @@ -import { useMemo, useState, useCallback } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useSettingsStore } from "@/stores"; import type { SlashCommandInfo } from "shared/legacy-sdk"; +import { rankSlashCommands } from "./slash-command-match"; interface ActiveToken { trigger: "/" | "@"; @@ -8,21 +9,6 @@ interface ActiveToken { query: string; } -function fuzzyMatch(text: string, query: string): boolean { - if (!query) { - return true; - } - const lowerText = text.toLowerCase(); - const lowerQuery = query.toLowerCase(); - let qi = 0; - for (let i = 0; i < lowerText.length && qi < lowerQuery.length; i++) { - if (lowerText[i] === lowerQuery[qi]) { - qi++; - } - } - return qi === lowerQuery.length; -} - export function findActiveToken(text: string, cursorPos: number): ActiveToken | null { const beforeCursor = text.slice(0, cursorPos); const lastSpace = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n"), beforeCursor.lastIndexOf("\t"), -1); @@ -56,17 +42,20 @@ export function useSlashMenu(activeToken: ActiveToken | null, onSelectCommand: ( if (!showSlashMenu) { return []; } - const q = activeToken.query; - if (!q) { - return slashCommands; - } - return slashCommands.filter((cmd) => fuzzyMatch(cmd.name, q) || fuzzyMatch(cmd.description, q)); + return rankSlashCommands(slashCommands, activeToken.query); }, [showSlashMenu, activeToken?.query, slashCommands]); const resetSlashMenu = useCallback(() => { setSelectedIndex(0); }, []); + // Every keystroke reorders the list, so a selection carried over from the + // previous query points at an unrelated command — or past the end of the list. + const query = showSlashMenu ? activeToken.query : ""; + useEffect(() => { + setSelectedIndex(0); + }, [query]); + const handleSlashMenuKey = useCallback( (e: React.KeyboardEvent): boolean => { if (!showSlashMenu || filteredCommands.length === 0) { diff --git a/apps/vscode/webview-ui/src/lib/tool-args.ts b/apps/vscode/webview-ui/src/lib/tool-args.ts new file mode 100644 index 00000000..5d7ee849 --- /dev/null +++ b/apps/vscode/webview-ui/src/lib/tool-args.ts @@ -0,0 +1,36 @@ +import type { UIToolCall } from "@/stores/chat.store"; + +export function parseArgs(args: string | null): Record { + if (!args) { + return {}; + } + try { + const parsed: unknown = JSON.parse(args); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { raw: args }; + } + return parsed as Record; + } catch { + return { raw: args }; + } +} + +export function getToolLabel(call: UIToolCall): string { + const args = parseArgs(call.arguments); + switch (call.name) { + case "Shell": + return (args.command as string) || "command"; + case "ReadFile": + case "WriteFile": + case "StrReplaceFile": + return (args.path as string)?.split("/").pop() || "file"; + case "Glob": + return (args.pattern as string) || "pattern"; + case "Task": + return (args.description as string) || "subagent task"; + case "SetTodoList": + return "Update Todos"; + default: + return ""; + } +} diff --git a/apps/vscode/webview-ui/src/lib/workflow-lanes.ts b/apps/vscode/webview-ui/src/lib/workflow-lanes.ts new file mode 100644 index 00000000..59117a5f --- /dev/null +++ b/apps/vscode/webview-ui/src/lib/workflow-lanes.ts @@ -0,0 +1,63 @@ +import type { UIStep, UISubagentStatus } from "@/stores/chat.store"; + +export interface WorkflowLane { + agentId: string; + label?: string; + index?: number; + steps: UIStep[]; + stepCount: number; + status: UISubagentStatus["status"]; + startedAt?: number; + endedAt?: number; + error?: string; + resultSummary?: string; +} + +/** Groups a DynamicWorkflow tool item's flat `subagent_steps` into per-agent lanes for WorkflowCard. */ +export function deriveWorkflowLanes(steps: UIStep[], statuses: Record): WorkflowLane[] { + const byAgent = new Map(); + + for (const step of steps) { + const agentId = step.agentId ?? ""; + const agentSteps = byAgent.get(agentId) ?? []; + agentSteps.push(step); + byAgent.set(agentId, agentSteps); + } + // A queued agent may have a status entry before it has produced a single step. + for (const agentId of Object.keys(statuses)) { + if (!byAgent.has(agentId)) { + byAgent.set(agentId, []); + } + } + + const lanes: WorkflowLane[] = []; + for (const [agentId, agentSteps] of byAgent) { + const status = statuses[agentId]; + const firstStep = agentSteps[0]; + lanes.push({ + agentId, + label: status?.label ?? firstStep?.agentLabel, + index: status?.index ?? firstStep?.agentIndex, + steps: agentSteps, + stepCount: agentSteps.length, + // `subagent.spawned` always precedes every other lifecycle event (see + // event-adapter.ts), so a lane with steps always has a status entry by + // the time this runs. This fallback is unreachable in practice; kept + // only because `statuses[agentId]` is not statically narrowed. + status: status?.status ?? "running", + startedAt: status?.startedAt, + endedAt: status?.endedAt, + error: status?.error, + resultSummary: status?.resultSummary, + }); + } + + lanes.sort((a, b) => (a.index ?? Number.MAX_SAFE_INTEGER) - (b.index ?? Number.MAX_SAFE_INTEGER)); + return lanes; +} + +/** The comparative-fill denominator: the busiest lane's step count. There is no + * per-agent step total, so progress bars read relative to each other, not absolute. */ +export function maxLaneStepCount(lanes: readonly WorkflowLane[]): number { + return lanes.reduce((max, lane) => Math.max(max, lane.stepCount), 0); +} diff --git a/apps/vscode/webview-ui/src/services/bridge.ts b/apps/vscode/webview-ui/src/services/bridge.ts index 47a52c21..b2505e77 100644 --- a/apps/vscode/webview-ui/src/services/bridge.ts +++ b/apps/vscode/webview-ui/src/services/bridge.ts @@ -10,9 +10,12 @@ import type { UpdateMCPServerRequest, } from "shared/legacy-sdk"; import type { + AddCatalogProviderRequest, + CatalogProviderSummary, FileChange, SessionConfig, ExtensionConfig, + ProvidersView, WorkspaceStatus, LoginStatus, UIStreamEvent, @@ -26,6 +29,8 @@ interface PendingRequest { const DEFAULT_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; const OAUTH_REQUEST_TIMEOUT_MS = 16 * 60 * 1000; +/** The catalog is ~1 MB over the public network, and an import writes config.toml. */ +const CATALOG_REQUEST_TIMEOUT_MS = 60 * 1000; interface VSCodeAPI { postMessage(message: unknown): void; @@ -158,6 +163,22 @@ class Bridge { return this.call(Methods.GetModels); } + getProviders() { + return this.call(Methods.GetProviders); + } + + getProviderCatalog() { + return this.call(Methods.GetProviderCatalog, undefined, CATALOG_REQUEST_TIMEOUT_MS); + } + + addCatalogProvider(request: AddCatalogProviderRequest) { + return this.call(Methods.AddCatalogProvider, request, CATALOG_REQUEST_TIMEOUT_MS); + } + + removeProvider(providerId: string) { + return this.call(Methods.RemoveProvider, { providerId }); + } + getMCPServers() { return this.call(Methods.GetMCPServers); } diff --git a/apps/vscode/webview-ui/src/stores/chat.store.ts b/apps/vscode/webview-ui/src/stores/chat.store.ts index ae12e314..de63e4d9 100644 --- a/apps/vscode/webview-ui/src/stores/chat.store.ts +++ b/apps/vscode/webview-ui/src/stores/chat.store.ts @@ -22,6 +22,19 @@ export interface UIStep { n: number; items: UIStepItem[]; planMode?: boolean; + agentId?: string; + agentLabel?: string; + agentIndex?: number; +} + +export interface UISubagentStatus { + status: "spawned" | "running" | "done" | "failed" | "suspended"; + label?: string; + index?: number; + startedAt?: number; + endedAt?: number; + error?: string; + resultSummary?: string; } export interface InlineError { @@ -41,6 +54,7 @@ export type UIStepItem = call: UIToolCall; result?: ToolResult["return_value"]; subagent_steps?: UIStep[]; + subagent_status?: Record; }; export interface ChatMessage { diff --git a/apps/vscode/webview-ui/src/stores/event-handlers.ts b/apps/vscode/webview-ui/src/stores/event-handlers.ts index 526324ea..2bd1ab0b 100644 --- a/apps/vscode/webview-ui/src/stores/event-handlers.ts +++ b/apps/vscode/webview-ui/src/stores/event-handlers.ts @@ -2,8 +2,8 @@ import { bridge } from "@/services"; import { useApprovalStore } from "./approval.store"; import { useSettingsStore } from "./settings.store"; import { isPreflightError, isUserInterrupt } from "shared/errors"; -import type { ChatMessage, UIStep, UIStepItem, ChatState, TokenUsage } from "./chat.store"; -import type { ContentPart, ToolCall, ToolResult, TurnBegin, SubagentEvent, ApprovalRequestPayload, DiffBlock, RunResult, QuestionRequest } from "shared/legacy-sdk"; +import type { ChatMessage, UIStep, UIStepItem, UISubagentStatus, ChatState, TokenUsage } from "./chat.store"; +import type { ContentPart, ToolCall, ToolResult, TurnBegin, SubagentEvent, SubagentStatusPayload, ApprovalRequestPayload, DiffBlock, RunResult, QuestionRequest } from "shared/legacy-sdk"; import type { UIStreamEvent, StreamError } from "shared/types"; type EventHandler = (draft: ChatState, payload: any) => void; @@ -63,11 +63,17 @@ function findToolUseItem(steps: UIStep[], toolId: string): (UIStepItem & { type: return null; } -function resolveSubagentTarget( - steps: UIStep[], - payload: SubagentEvent, -): { steps: UIStep[]; event: { type: string; payload: any }; toolItem: UIStepItem & { type: "tool_use" } } | null { - const { parent_tool_call_id, event } = payload; +interface SubagentTarget { + steps: UIStep[]; + event: { type: string; payload: any }; + toolItem: UIStepItem & { type: "tool_use" }; + agentId: string; + agentLabel?: string; + agentIndex?: number; +} + +function resolveSubagentTarget(steps: UIStep[], payload: SubagentEvent): SubagentTarget | null { + const { parent_tool_call_id, event, agent_id, agent_label, agent_index } = payload; // Nested SubagentEvent if (event.type === "SubagentEvent") { @@ -83,7 +89,40 @@ function resolveSubagentTarget( toolItem.subagent_steps = []; } - return { steps: toolItem.subagent_steps, event, toolItem }; + return { + steps: toolItem.subagent_steps, + event, + toolItem, + agentId: agent_id, + agentLabel: agent_label, + agentIndex: agent_index, + }; +} + +function applySubagentStatus( + toolItem: UIStepItem & { type: "tool_use" }, + payload: SubagentStatusPayload, +): void { + if (!toolItem.subagent_status) { + toolItem.subagent_status = {}; + } + const existing = toolItem.subagent_status[payload.agent_id]; + + const status: UISubagentStatus = { + status: payload.status, + label: payload.agent_label ?? existing?.label, + index: payload.agent_index ?? existing?.index, + // First transition to "running" stamps the start; the engine sends no timestamps + // of its own, so wall-clock lane duration is only as precise as Date.now() here. + startedAt: payload.status === "running" ? (existing?.startedAt ?? Date.now()) : existing?.startedAt, + endedAt: + payload.status === "done" || payload.status === "failed" || payload.status === "suspended" + ? (existing?.endedAt ?? Date.now()) + : existing?.endedAt, + error: payload.error ?? existing?.error, + resultSummary: payload.result_summary ?? existing?.resultSummary, + }; + toolItem.subagent_status[payload.agent_id] = status; } function finishAllTextItems(steps: UIStep[]): void { @@ -99,8 +138,29 @@ function finishAllTextItems(steps: UIStep[]): void { } } -function applyEventToSteps(steps: UIStep[], event: { type: string; payload: any }, onText?: (text: string) => void): void { - const currentStep = steps.at(-1); +interface StepAgent { + id: string; + label?: string; + index?: number; +} + +function findLastStepForAgent(steps: UIStep[], agentId: string): UIStep | undefined { + for (let i = steps.length - 1; i >= 0; i--) { + const step = steps[i]; + if (step?.agentId === agentId) return step; + } + return undefined; +} + +function applyEventToSteps( + steps: UIStep[], + event: { type: string; payload: any }, + onText?: (text: string) => void, + agent?: StepAgent, +): void { + // When an agent is known, target its own latest step rather than the array's + // tail — two agents can otherwise land text/tool calls in each other's step. + const currentStep = agent ? findLastStepForAgent(steps, agent.id) : steps.at(-1); const appendOrCreate = (type: "text" | "thinking", content: string): void => { if (!currentStep) { @@ -117,6 +177,9 @@ function applyEventToSteps(steps: UIStep[], event: { type: string; payload: any const findLastToolUse = (): (UIStepItem & { type: "tool_use" }) | null => { for (let i = steps.length - 1; i >= 0; i--) { + if (agent && steps[i].agentId !== agent.id) { + continue; + } const items = steps[i].items; for (let j = items.length - 1; j >= 0; j--) { @@ -129,48 +192,42 @@ function applyEventToSteps(steps: UIStep[], event: { type: string; payload: any return null; }; - const updateToolResult = (toolCallId: string, returnValue: ToolResult["return_value"]): boolean => { - for (const step of steps) { - for (const item of step.items) { - if (item.type === "tool_use") { - if (item.id === toolCallId) { - item.result = returnValue; - return true; - } - - if (item.subagent_steps && applyToolResult(item.subagent_steps, toolCallId, returnValue)) { - return true; - } - } - } - } - - return false; - }; - - const applyToolResult = (subSteps: UIStep[], toolCallId: string, returnValue: ToolResult["return_value"]): boolean => { + // Sets the result and returns the tool item, so the ToolResult handler below + // can reuse it (e.g. for the batch-abort freeze) without a second tree walk. + const setToolResult = ( + subSteps: UIStep[], + toolCallId: string, + returnValue: ToolResult["return_value"], + ): (UIStepItem & { type: "tool_use" }) | null => { for (const step of subSteps) { for (const item of step.items) { if (item.type === "tool_use") { if (item.id === toolCallId) { item.result = returnValue; - return true; + return item; } - if (item.subagent_steps && applyToolResult(item.subagent_steps, toolCallId, returnValue)) { - return true; + if (item.subagent_steps) { + const found = setToolResult(item.subagent_steps, toolCallId, returnValue); + if (found) return found; } } } } - return false; + return null; }; switch (event.type) { case "StepBegin": finishAllTextItems(steps); - steps.push({ n: event.payload.n, items: [] }); + steps.push({ + n: event.payload.n, + items: [], + agentId: agent?.id, + agentLabel: agent?.label, + agentIndex: agent?.index, + }); break; case "ContentPart": { @@ -224,7 +281,18 @@ function applyEventToSteps(steps: UIStep[], event: { type: string; payload: any case "ToolResult": { const result = event.payload as ToolResult; - updateToolResult(result.tool_call_id, result.return_value); + const toolItem = setToolResult(steps, result.tool_call_id, result.return_value); + + // Batch aborted: lanes still spawned/running when the parent result lands + // have no further lifecycle events coming, so freeze them as failed. + if (result.return_value.is_error && toolItem?.subagent_status) { + for (const status of Object.values(toolItem.subagent_status)) { + if (status.status === "spawned" || status.status === "running") { + status.status = "failed"; + status.endedAt = status.endedAt ?? Date.now(); + } + } + } const paths = extractDiffPaths(result.return_value.display); if (paths.length > 0) { @@ -486,8 +554,22 @@ const eventHandlers: Record = { return; } - if (target.steps.length === 0) { - target.steps.push({ n: 1, items: [] }); + if (target.event.type === "SubagentStatus") { + applySubagentStatus(target.toolItem, target.event.payload as SubagentStatusPayload); + return; + } + + // Seeds a step only for an agent whose first event arrives before its own + // StepBegin. Seeding on StepBegin too would leave every lane with a leading + // empty step and inflate its step count by one. + if (target.event.type !== "StepBegin" && !target.steps.some((s) => s.agentId === target.agentId)) { + target.steps.push({ + n: 1, + items: [], + agentId: target.agentId, + agentLabel: target.agentLabel, + agentIndex: target.agentIndex, + }); } // Nested Subagent Task End: accumulate token usage @@ -499,7 +581,26 @@ const eventHandlers: Record = { } } - applyEventToSteps(target.steps, target.event); + applyEventToSteps( + target.steps, + target.event, + undefined, + { id: target.agentId, label: target.agentLabel, index: target.agentIndex }, + ); + }, + + SubagentStatus: (draft, payload: SubagentStatusPayload) => { + const last = getLastAssistant(draft); + if (!last?.steps) { + return; + } + + const toolItem = findToolUseItem(last.steps, payload.parent_tool_call_id); + if (!toolItem) { + return; + } + + applySubagentStatus(toolItem, payload); }, ApprovalRequest: (_, payload: ApprovalRequestPayload) => { diff --git a/apps/vscode/webview-ui/src/stores/settings.store.ts b/apps/vscode/webview-ui/src/stores/settings.store.ts index 42876898..9f57c5c9 100644 --- a/apps/vscode/webview-ui/src/stores/settings.store.ts +++ b/apps/vscode/webview-ui/src/stores/settings.store.ts @@ -5,7 +5,7 @@ import type { ExtensionConfig } from "shared/types"; import type { MCPServerConfig, ModelConfig, ThinkingMode, SlashCommandInfo } from "shared/legacy-sdk"; let settingsSaveRevision = 0; -const MANAGED_PYTHINKER_CODE_PROVIDER = "managed:pythinker-code"; +const MANAGED_KIMI_CODE_PROVIDER = "managed:kimi-code"; function saveConfigWithRollback( config: Parameters[0], @@ -47,7 +47,7 @@ export function getModelThinkingMode(model: ModelConfig): ThinkingMode { } export function providerDisplayName(provider: string): string { - if (provider === MANAGED_PYTHINKER_CODE_PROVIDER) return "Pythinker Code"; + if (provider === MANAGED_KIMI_CODE_PROVIDER) return "Kimi Code"; if (provider.startsWith("managed:")) return provider.slice("managed:".length); return provider; } @@ -84,7 +84,7 @@ export function requiresManagedProviderLogin( ): boolean { if (loggedIn) return false; const activeModel = getModelById(models, defaultModel ?? "") ?? models[0]; - return activeModel?.provider === MANAGED_PYTHINKER_CODE_PROVIDER; + return activeModel?.provider === MANAGED_KIMI_CODE_PROVIDER; } function defaultEffortForModel(model: ModelConfig, defaultThinking: boolean, configuredEffort?: string): string { @@ -145,6 +145,7 @@ interface SettingsState { extensionConfig: ExtensionConfig; mcpServers: MCPServerConfig[]; mcpModalOpen: boolean; + providersModalOpen: boolean; workDirModalOpen: boolean; settingsDialogOpen: boolean; currentWorkDir: string | null; @@ -166,6 +167,7 @@ interface SettingsState { setExtensionConfig: (config: ExtensionConfig) => void; setMCPServers: (servers: MCPServerConfig[]) => void; setMCPModalOpen: (open: boolean) => void; + setProvidersModalOpen: (open: boolean) => void; setWorkDirModalOpen: (open: boolean) => void; setSettingsDialogOpen: (open: boolean) => void; setCurrentWorkDir: (workDir: string | null) => void; @@ -182,6 +184,7 @@ export const useSettingsStore = create((set, get) => ({ extensionConfig: DEFAULT_EXTENSION_CONFIG, mcpServers: [], mcpModalOpen: false, + providersModalOpen: false, workDirModalOpen: false, settingsDialogOpen: false, currentWorkDir: null, @@ -281,6 +284,7 @@ export const useSettingsStore = create((set, get) => ({ setMCPServers: (mcpServers) => set({ mcpServers }), setMCPModalOpen: (mcpModalOpen) => set({ mcpModalOpen }), + setProvidersModalOpen: (providersModalOpen) => set({ providersModalOpen }), setWorkDirModalOpen: (workDirModalOpen) => set({ workDirModalOpen }), diff --git a/apps/vscode/webview-ui/src/styles/index.css b/apps/vscode/webview-ui/src/styles/index.css index fc1b716e..9a4a2a96 100644 --- a/apps/vscode/webview-ui/src/styles/index.css +++ b/apps/vscode/webview-ui/src/styles/index.css @@ -14,20 +14,18 @@ --card-foreground: oklch(0.141 0.005 285.823); --popover: oklch(1 0 0); --popover-foreground: oklch(0.141 0.005 285.823); - /* Brand periwinkle, matching the CLI (lightColors.primary). Was - oklch(0.21) — a near-black that made every accent surface read as an - unstyled dark chip. */ - --primary: #4a5bc4; + --primary: oklch(0.21 0.006 285.885); --primary-foreground: oklch(1 0 0); --secondary: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.21 0.006 285.885); --muted: oklch(0.967 0.001 286.375); - /* Periwinkle, matching the CLI palette (lightColors.primary). Plain grey at - this size read as disabled rather than secondary. */ - --muted-foreground: #4a5bc4; + --muted-foreground: oklch(0.552 0.016 285.938); --accent: oklch(0.967 0.001 286.375); --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); + /* Muted periwinkle accent (CLI primary). Used for inline code and progress + fills — --primary is a near-black neutral and disappears against --muted. */ + --brand: #4a5bc4; /* CLI lightColors.success — the "on" colour for toggles. */ --success: #0e7a38; --success-foreground: oklch(1 0 0); @@ -43,20 +41,17 @@ --card-foreground: oklch(0.985 0 0); --popover: oklch(0.18 0.005 285.823); --popover-foreground: oklch(0.985 0 0); - /* Brand periwinkle, matching the CLI (darkColors.primary). The old - oklch(0.21) was DARKER than --input (oklch 0.274) and barely above the - background, so accent surfaces and "on" states disappeared in dark mode. */ - --primary: #bbc6ff; - --primary-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.274 0.006 286.033); - /* Periwinkle, matching the CLI palette (darkColors.primary). The previous - near-neutral grey sat too close to the background to read at 11px. */ - --muted-foreground: #bbc6ff; + --muted-foreground: oklch(0.705 0.015 286.067); --accent: oklch(0.274 0.006 286.033); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); + /* Muted periwinkle accent (CLI primary), dark variant. */ + --brand: #aab3e8; /* CLI darkColors.success — the "on" colour for toggles. */ --success: #4ec87e; --success-foreground: oklch(0.141 0.005 285.823); @@ -110,6 +105,7 @@ body { --color-destructive: var(--destructive); --color-success: var(--success); --color-success-foreground: var(--success-foreground); + --color-brand: var(--brand); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); diff --git a/apps/vscode/webview-ui/tsconfig.json b/apps/vscode/webview-ui/tsconfig.json index 8ed30156..5574f36f 100644 --- a/apps/vscode/webview-ui/tsconfig.json +++ b/apps/vscode/webview-ui/tsconfig.json @@ -20,5 +20,5 @@ "shared/*": ["../shared/*"] } }, - "include": ["src", "../test/settings-store.test.ts", "../test/app-init.test.ts"] + "include": ["src", "../test/settings-store.test.ts", "../test/app-init.test.ts", "../test/event-handlers.test.ts"] } diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index ccb2bb57..1117cc5c 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -30,13 +30,13 @@ default_plan_mode = false merge_all_available_skills = true telemetry = true -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.pythinker.com/coding/v1" api_key = "" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 diff --git a/flake.nix b/flake.nix index b0511937..27c405f1 100644 --- a/flake.nix +++ b/flake.nix @@ -148,7 +148,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-3bo6StR6vVFZ6ur9p67gIhEzpWzUZ+JzW6O50k13oag="; + hash = "sha256-9DehARrDfyGqxEqDmfeYTw1tIFqNQVxpOpiaW6QY4Zw="; }; nativeBuildInputs = [ diff --git a/package.json b/package.json index 4eb27d9f..5979aecb 100644 --- a/package.json +++ b/package.json @@ -64,5 +64,8 @@ "diff@>=6.0.0 <8.0.3": "^8.0.3", "serialize-javascript@<7.0.5": "^7.0.5" } + }, + "dependencies": { + "@earendil-works/pi-tui": "^0.83.0" } } diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 4aa910d2..87666649 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -387,6 +387,10 @@ export interface ListAgentProfilesPayload { readonly workDir: string; } +export interface ListWorkspaceSkillsPayload { + readonly workDir: string; +} + export interface AgentProfileSummary { readonly name: string; readonly description?: string; @@ -494,6 +498,7 @@ export interface CoreAPI extends SessionAPIWithId { getConfigDiagnostics: (payload: EmptyPayload) => ConfigDiagnostics; listOutputStyles: (payload: ListOutputStylesPayload) => OutputStyleCatalog; listAgentProfiles: (payload: ListAgentProfilesPayload) => AgentProfileCatalog; + listWorkspaceSkills: (payload: ListWorkspaceSkillsPayload) => readonly SkillSummary[]; setPythinkerConfig: (payload: SetPythinkerConfigPayload) => PythinkerConfig; replacePythinkerConfig: (payload: ReplacePythinkerConfigPayload) => PythinkerConfig; removePythinkerProvider: (payload: RemovePythinkerProviderPayload) => PythinkerConfig; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index b346fc83..82942559 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -1,3 +1,4 @@ +import { KIMI_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; import { randomUUID } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; @@ -34,6 +35,7 @@ import { import { FLAG_DEFINITIONS, FlagResolver, type ExperimentalFeatureState } from '../flags'; import type { Logger } from '../logging/types'; import { resolveSessionMcpConfig, mergeCallerMcpServers, type SessionMcpConfig } from '../mcp'; +import { listWorkspaceSkills } from '../skill/workspace'; import { DEFAULT_AGENT_PROFILES, loadAgentProfilesFromDirectories, @@ -91,6 +93,7 @@ import type { InstallPluginPayload, ListSessionsPayload, ListAgentProfilesPayload, + ListWorkspaceSkillsPayload, ListOutputStylesPayload, McpServerInfo, SkillActivationResult, @@ -132,7 +135,6 @@ import type { ResumedAgentState, ResumeSessionResult } from './resumed'; import type { SDKRPC } from './sdk-api'; import { proxyWithExtraPayload } from './types'; -const PYTHINKER_CODE_PROVIDER_NAME = 'managed:pythinker-code'; const PYTHINKER_CODE_BASE_URL_ENV = 'PYTHINKER_CODE_BASE_URL'; const PYTHINKER_CODE_OAUTH_HOST_ENV = 'PYTHINKER_CODE_OAUTH_HOST'; const PYTHINKER_OAUTH_HOST_ENV = 'PYTHINKER_OAUTH_HOST'; @@ -615,6 +617,21 @@ export class PythinkerCore implements PromisableMethods { return this.loadAgentProfileCatalog(requiredWorkDir('listAgentProfiles', workDir)); } + // Resolves the same roots a session would, so a caller that has no session yet + // (a freshly opened editor panel) still sees the workspace's real skill list. + async listWorkspaceSkills({ + workDir, + }: ListWorkspaceSkillsPayload): Promise { + await this.pluginsReady; + this.assertPluginsLoaded(); + return listWorkspaceSkills({ + workDir: requiredWorkDir('listWorkspaceSkills', workDir), + // The lenient runtime read, matching session creation: a warning in an + // unrelated config section must not make listing skills throw. + ...this.resolveSessionSkillConfig(this.reloadRuntimeConfig()), + }); + } + async setPythinkerConfig(input: SetPythinkerConfigPayload): Promise { const config = mergeConfigPatch(this.readConfigForWrite(), input); return this.writePythinkerConfig(config); @@ -1114,7 +1131,7 @@ export class PythinkerCore implements PromisableMethods { } private mergePluginMcpConfig(base: SessionMcpConfig | undefined): SessionMcpConfig | undefined { - const pluginServers = this.withManagedPythinkerPluginEnv(this.plugins.enabledMcpServers()); + const pluginServers = this.withManagedKimiPluginEnv(this.plugins.enabledMcpServers()); if (Object.keys(pluginServers).length === 0) return base; return { servers: { @@ -1124,7 +1141,7 @@ export class PythinkerCore implements PromisableMethods { }; } - private withManagedPythinkerPluginEnv( + private withManagedKimiPluginEnv( pluginServers: Record, ): Record { const managedEnv = this.managedPythinkerCodeEnvForPlugins(); @@ -1141,7 +1158,7 @@ export class PythinkerCore implements PromisableMethods { } private managedPythinkerCodeEnvForPlugins(): Record { - const provider = this.config.providers[PYTHINKER_CODE_PROVIDER_NAME]; + const provider = this.config.providers[KIMI_CODE_PROVIDER_NAME]; const envBaseUrl = process.env[PYTHINKER_CODE_BASE_URL_ENV]; const envOAuthHost = process.env[PYTHINKER_CODE_OAUTH_HOST_ENV] ?? process.env[PYTHINKER_OAUTH_HOST_ENV]; @@ -1345,7 +1362,7 @@ function serviceCredentials( apiKey, tokenProvider: service.oauth !== undefined - ? resolveOAuthTokenProvider?.(PYTHINKER_CODE_PROVIDER_NAME, service.oauth) + ? resolveOAuthTokenProvider?.(KIMI_CODE_PROVIDER_NAME, service.oauth) : undefined, customHeaders: service.customHeaders, }; diff --git a/packages/agent-core/src/services/auth/managedAuth.ts b/packages/agent-core/src/services/auth/managedAuth.ts index aa39a224..4e66acf7 100644 --- a/packages/agent-core/src/services/auth/managedAuth.ts +++ b/packages/agent-core/src/services/auth/managedAuth.ts @@ -2,20 +2,20 @@ import { readConfigFile, writeConfigFile } from '../../config'; import type { PythinkerConfig, OAuthRef } from '../../config'; import type { OAuthTokenProviderResolver } from '../../session/provider-manager'; import { - applyManagedPythinkerCodeConfig, - applyManagedPythinkerCodeLogoutConfig, - PYTHINKER_CODE_PROVIDER_NAME, + applyManagedKimiCodeConfig, + applyManagedKimiCodeLogoutConfig, + KIMI_CODE_PROVIDER_NAME, PythinkerOAuthToolkit, - resolvePythinkerCodeLoginAuth, - resolvePythinkerCodeRuntimeAuth, + resolveKimiCodeLoginAuth, + resolveKimiCodeRuntimeAuth, type BearerTokenProvider, type PythinkerOAuthLoginOptions, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '@pythoughts/pythinker-code-oauth'; import type { IEnvironmentService } from '../environment/environment'; -type ServicesManagedConfig = PythinkerConfig & ManagedPythinkerConfigShape; +type ServicesManagedConfig = PythinkerConfig & ManagedKimiConfigShape; type ServicesAuthLoginOptions = Omit; @@ -59,18 +59,18 @@ class ServicesManagedAuthFacade implements ServicesAuthFacade { write: async (config) => { await writeConfigFile(options.configPath, config); }, - apply: applyManagedPythinkerCodeConfig, - remove: applyManagedPythinkerCodeLogoutConfig, + apply: applyManagedKimiCodeConfig, + remove: applyManagedKimiCodeLogoutConfig, }, }); } async login( - providerName: string | undefined = PYTHINKER_CODE_PROVIDER_NAME, + providerName: string | undefined = KIMI_CODE_PROVIDER_NAME, options: ServicesAuthLoginOptions = {}, ): Promise { const auth = this.resolveManagedAuth(providerName); - const loginAuth = resolvePythinkerCodeLoginAuth({ + const loginAuth = resolveKimiCodeLoginAuth({ configuredBaseUrl: auth.baseUrl, configuredOAuthRef: auth.oauthRef, requestedBaseUrl: options.baseUrl, @@ -132,7 +132,7 @@ class ServicesManagedAuthFacade implements ServicesAuthFacade { readonly oauthRef?: OAuthRef | undefined; readonly baseUrl?: string | undefined; } { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const config = readConfigFile(this.options.configPath); const provider = config.providers[name]; return { @@ -146,7 +146,7 @@ class ServicesManagedAuthFacade implements ServicesAuthFacade { readonly baseUrl?: string | undefined; } { const auth = this.resolveManagedAuth(providerName); - return resolvePythinkerCodeRuntimeAuth({ + return resolveKimiCodeRuntimeAuth({ configuredBaseUrl: auth.baseUrl, configuredOAuthRef: auth.oauthRef, }); @@ -156,11 +156,11 @@ class ServicesManagedAuthFacade implements ServicesAuthFacade { providerName: string | undefined, oauthRef?: OAuthRef | undefined, ): OAuthRef | undefined { - if ((providerName ?? PYTHINKER_CODE_PROVIDER_NAME) !== PYTHINKER_CODE_PROVIDER_NAME) { + if ((providerName ?? KIMI_CODE_PROVIDER_NAME) !== KIMI_CODE_PROVIDER_NAME) { return oauthRef; } const auth = this.resolveManagedAuth(providerName); - return resolvePythinkerCodeRuntimeAuth({ + return resolveKimiCodeRuntimeAuth({ configuredBaseUrl: auth.baseUrl, configuredOAuthRef: oauthRef ?? auth.oauthRef, }).oauthRef; diff --git a/packages/agent-core/src/services/authSummary/authSummaryService.ts b/packages/agent-core/src/services/authSummary/authSummaryService.ts index 653bb049..ff35e5cb 100644 --- a/packages/agent-core/src/services/authSummary/authSummaryService.ts +++ b/packages/agent-core/src/services/authSummary/authSummaryService.ts @@ -2,6 +2,7 @@ * `AuthSummaryService` — implementation of `IAuthSummaryService`. */ +import { KIMI_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; import { Disposable, InstantiationType, registerSingleton } from '../../di'; import { resolveProviderApiKey, type PythinkerConfig } from '../../config'; import type { AuthSummary } from '@pythoughts/protocol'; @@ -15,8 +16,6 @@ import { AuthModelNotResolvedError, } from './authSummary'; -/** Wire name of the OAuth-managed provider (`@pythoughts/pythinker-code-oauth`'s `PYTHINKER_CODE_PROVIDER_NAME`). */ -const MANAGED_PROVIDER_NAME = 'managed:pythinker-code'; export class AuthSummaryService extends Disposable @@ -40,10 +39,10 @@ export class AuthSummaryService const default_model = nonEmpty(config.defaultModel); let managed_provider: AuthSummary['managed_provider'] = null; - if (providers[MANAGED_PROVIDER_NAME] !== undefined) { - const hasToken = await this._hasCachedToken(MANAGED_PROVIDER_NAME); + if (providers[KIMI_CODE_PROVIDER_NAME] !== undefined) { + const hasToken = await this._hasCachedToken(KIMI_CODE_PROVIDER_NAME); managed_provider = { - name: MANAGED_PROVIDER_NAME, + name: KIMI_CODE_PROVIDER_NAME, status: hasToken ? 'authenticated' : 'unauthenticated', }; } diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts index f428ac11..16fd422d 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts @@ -12,12 +12,12 @@ import type { SetDefaultModelResponse, } from '@pythoughts/protocol'; import { - PYTHINKER_CODE_PLATFORM_ID, - PYTHINKER_CODE_PROVIDER_NAME, - applyManagedPythinkerCodeConfig, - fetchManagedPythinkerCodeModels, - resolvePythinkerCodeRuntimeAuth, - type ManagedPythinkerConfigShape, + KIMI_CODE_PLATFORM_ID, + KIMI_CODE_PROVIDER_NAME, + applyManagedKimiCodeConfig, + fetchManagedKimiCodeModels, + resolveKimiCodeRuntimeAuth, + type ManagedKimiConfigShape, } from '@pythoughts/pythinker-code-oauth'; import { createManagedAuthFacade, type ServicesAuthFacade } from '../auth/managedAuth'; @@ -101,32 +101,32 @@ export class ModelCatalogService const changed: RefreshOAuthProviderModelsResponse['changed'] = []; const unchanged: string[] = []; const failed: RefreshOAuthProviderModelsResponse['failed'] = []; - const provider = config.providers?.[PYTHINKER_CODE_PROVIDER_NAME]; + const provider = config.providers?.[KIMI_CODE_PROVIDER_NAME]; if (provider?.type !== 'pythinker' || provider.oauth === undefined) { return { changed, unchanged, failed }; } try { - const auth = resolvePythinkerCodeRuntimeAuth({ + const auth = resolveKimiCodeRuntimeAuth({ configuredBaseUrl: provider.baseUrl, configuredOAuthRef: provider.oauth, }); const tokenProvider = this._authFacade.resolveOAuthTokenProvider( - PYTHINKER_CODE_PROVIDER_NAME, + KIMI_CODE_PROVIDER_NAME, auth.oauthRef, ); if (tokenProvider === undefined) { throw new Error('OAuth token provider is not configured.'); } const token = await tokenProvider.getAccessToken(); - const models = await fetchManagedPythinkerCodeModels({ + const models = await fetchManagedKimiCodeModels({ accessToken: token, baseUrl: auth.baseUrl, }); if (models.length === 0) return { changed, unchanged, failed }; const next = structuredClone(config); - applyManagedPythinkerCodeConfig(next as unknown as ManagedPythinkerConfigShape, { + applyManagedKimiCodeConfig(next as unknown as ManagedKimiConfigShape, { models, baseUrl: auth.baseUrl, oauthKey: auth.oauthRef.key, @@ -136,24 +136,24 @@ export class ModelCatalogService const refreshedAliasKeys = providerRefreshAliasKeys( config, next, - PYTHINKER_CODE_PROVIDER_NAME, - `${PYTHINKER_CODE_PLATFORM_ID}/`, + KIMI_CODE_PROVIDER_NAME, + `${KIMI_CODE_PLATFORM_ID}/`, ); restoreProviderAliases( next, - preserveUserProviderAliases(config, PYTHINKER_CODE_PROVIDER_NAME, refreshedAliasKeys), + preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), ); restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); clampDanglingDefault(next); - if (providerModelsEqual(config, next, PYTHINKER_CODE_PROVIDER_NAME, refreshedAliasKeys)) { - unchanged.push(PYTHINKER_CODE_PROVIDER_NAME); + if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { + unchanged.push(KIMI_CODE_PROVIDER_NAME); } else { const { added, removed } = computeChanges( collectModelIdsForAliases(config, refreshedAliasKeys), collectModelIdsForAliases(next, refreshedAliasKeys), ); - await this.core.rpc.removePythinkerProvider({ providerId: PYTHINKER_CODE_PROVIDER_NAME }); + await this.core.rpc.removePythinkerProvider({ providerId: KIMI_CODE_PROVIDER_NAME }); await this.core.rpc.setPythinkerConfig({ providers: next.providers, models: next.models, @@ -161,7 +161,7 @@ export class ModelCatalogService defaultThinking: next.defaultThinking, }); changed.push({ - provider_id: PYTHINKER_CODE_PROVIDER_NAME, + provider_id: KIMI_CODE_PROVIDER_NAME, provider_name: 'Pythinker Code', added, removed, @@ -169,7 +169,7 @@ export class ModelCatalogService } } catch (error) { failed.push({ - provider: PYTHINKER_CODE_PROVIDER_NAME, + provider: KIMI_CODE_PROVIDER_NAME, reason: error instanceof Error ? error.message : String(error), }); } diff --git a/packages/agent-core/src/services/oauth/oauth.ts b/packages/agent-core/src/services/oauth/oauth.ts index 948d843e..a919491f 100644 --- a/packages/agent-core/src/services/oauth/oauth.ts +++ b/packages/agent-core/src/services/oauth/oauth.ts @@ -19,7 +19,7 @@ * 3. Same-provider new flow superseding (→ 'cancelled') * * **Token + config** land via the toolkit's provisioning path: on success, - * the `managed:pythinker-code` provider + models entry are written to + * the `managed:kimi-code` provider + models entry are written to * `config.toml`, and the cached token is saved to credentials. Frontend * follow-up: hit `GET /v1/auth` to confirm `ready: true`. * @@ -75,7 +75,7 @@ export interface IOAuthService { /** * Kick off a device-code flow for `providerName` (default - * `'managed:pythinker-code'`). Requests the device authorization synchronously + * `'managed:kimi-code'`). Requests the device authorization synchronously * (1-2 round-trips to the OAuth host), starts background polling, and * returns the verification URLs + flow_id. * diff --git a/packages/agent-core/src/services/oauth/oauthService.ts b/packages/agent-core/src/services/oauth/oauthService.ts index 66631c88..7f789fc2 100644 --- a/packages/agent-core/src/services/oauth/oauthService.ts +++ b/packages/agent-core/src/services/oauth/oauthService.ts @@ -6,7 +6,7 @@ import { Disposable, DisposableMap, InstantiationType, registerSingleton } from import type { IDisposable } from '../../di'; import { DeviceCodeTimeoutError, - PYTHINKER_CODE_PROVIDER_NAME, + KIMI_CODE_PROVIDER_NAME, OAuthError, type DeviceAuthorization, } from '@pythoughts/pythinker-code-oauth'; @@ -94,7 +94,7 @@ export class OAuthService extends Disposable implements IOAuthService { } async startLogin(providerName?: string): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; // Supersede any existing pending flow. const existing = this._flows.get(name); @@ -181,14 +181,14 @@ export class OAuthService extends Disposable implements IOAuthService { } getFlow(providerName?: string): OAuthFlowSnapshot | undefined { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const state = this._flows.get(name); if (state === undefined) return undefined; return this._toSnapshot(state); } async cancelLogin(providerName?: string): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const state = this._flows.get(name); if (state === undefined) { // No flow at all → treat as "already cancelled" (idempotent). @@ -203,7 +203,7 @@ export class OAuthService extends Disposable implements IOAuthService { } async logout(providerName?: string): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; // Also cancel any in-flight flow so the next `GET /v1/auth` sees a clean // slate. const pending = this._flows.get(name); diff --git a/packages/agent-core/src/skill/index.ts b/packages/agent-core/src/skill/index.ts index 924027bf..577f10e0 100644 --- a/packages/agent-core/src/skill/index.ts +++ b/packages/agent-core/src/skill/index.ts @@ -3,3 +3,4 @@ export * from './parser'; export * from './registry'; export * from './scanner'; export * from './types'; +export * from './workspace'; diff --git a/packages/agent-core/src/skill/workspace.ts b/packages/agent-core/src/skill/workspace.ts new file mode 100644 index 00000000..3ec5e434 --- /dev/null +++ b/packages/agent-core/src/skill/workspace.ts @@ -0,0 +1,52 @@ +import { homedir } from 'node:os'; + +import { registerBuiltinSkills } from './builtin'; +import { SessionSkillRegistry } from './registry'; +import { resolveSkillRoots } from './scanner'; +import { summarizeSkill, type SkillRoot, type SkillSummary } from './types'; + +/** + * Field-for-field the resolution inputs a session uses, so a caller can pass the + * config it already assembles for sessions. Declared here rather than imported + * from the session layer, which depends on this one. + */ +export interface ListWorkspaceSkillsOptions { + /** Directory the skills are resolved for, as a session's cwd would be. */ + readonly workDir: string; + readonly userHomeDir?: string; + /** Brand data dir (PYTHINKER_CODE_HOME); user brand skills live under `/skills`. */ + readonly brandHomeDir?: string; + readonly explicitDirs?: readonly string[]; + readonly extraDirs?: readonly string[]; + readonly pluginSkillRoots?: readonly SkillRoot[]; + readonly mergeAllAvailableSkills?: boolean; + readonly builtinDir?: string; + readonly onWarning?: (message: string, cause?: unknown) => void; +} + +/** + * The skill catalog a workspace resolves to, without opening a session. + * + * A session's own `listSkills` additionally reports the prompts of its MCP + * connections; those belong to a live session and are absent here. + */ +export async function listWorkspaceSkills( + options: ListWorkspaceSkillsOptions, +): Promise { + const registry = new SessionSkillRegistry({ onWarning: options.onWarning }); + const roots = await resolveSkillRoots({ + paths: { + userHomeDir: options.userHomeDir ?? homedir(), + brandHomeDir: options.brandHomeDir, + workDir: options.workDir, + }, + explicitDirs: options.explicitDirs, + extraDirs: options.extraDirs, + pluginSkillRoots: options.pluginSkillRoots, + mergeAllAvailableSkills: options.mergeAllAvailableSkills, + builtinDir: options.builtinDir, + }); + await registry.loadRoots(roots); + registerBuiltinSkills(registry); + return registry.listSkills().map(summarizeSkill); +} diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index 072b97bf..951c0656 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -2126,15 +2126,15 @@ function oauthTestAgentOptions( initialConfig: { defaultModel: 'pythinker-code', providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'vertexai', baseUrl: 'https://api.example/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { 'pythinker-code': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 1_000_000, }, diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index bd702815..f5a98508 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -1221,7 +1221,7 @@ describe('Agent turn flow', () => { tokenCalls.push(options?.force); throw new PythinkerError( ErrorCodes.PROVIDER_CONNECTION_ERROR, - 'OAuth provider "managed:pythinker-code" failed to fetch an access token: fetch failed', + 'OAuth provider "managed:kimi-code" failed to fetch an access token: fetch failed', ); }); const generate = vi.fn(); @@ -2024,15 +2024,15 @@ function oauthAgentOptions( initialConfig: { defaultModel: 'pythinker-code', providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'vertexai', baseUrl: 'https://api.example/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { 'pythinker-code': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 1_000_000, capabilities: capabilities === undefined ? undefined : [...capabilities], diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index c045c998..b452bad4 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -59,17 +59,17 @@ additional_dirs = ["~/shared-project", "/tmp/reference"] telemetry = false theme = "dark" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.pythinker.com/coding/v1" api_key = "sk-file" custom_headers = { "X-Test" = "1" } -[providers."managed:pythinker-code".env] +[providers."managed:kimi-code".env] GOOGLE_CLOUD_PROJECT = "project-1" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 capabilities = ["image_in", "thinking", "video_in"] @@ -140,7 +140,7 @@ describe('harness config TOML loader', () => { expect(config.extraSkillDirs).toEqual(['~/team-skills', '.agents/team-skills']); expect(config.additionalDirs).toEqual(['~/shared-project', '/tmp/reference']); expect(config.telemetry).toBe(false); - expect(config.providers['managed:pythinker-code']).toMatchObject({ + expect(config.providers['managed:kimi-code']).toMatchObject({ type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', apiKey: 'sk-file', @@ -148,7 +148,7 @@ describe('harness config TOML loader', () => { customHeaders: { 'X-Test': '1' }, }); expect(config.models?.['pythinker-code/pythinker-for-coding']).toMatchObject({ - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['image_in', 'thinking', 'video_in'], @@ -255,21 +255,21 @@ api_key_env_var = "DEEPSEEK_API_KEY" const dir = makeTempDir(); const configPath = join(dir, 'oauth-ref.toml'); const toml = ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.dev.example.test/coding/v1" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code-env-1234", oauth_host = "https://auth.dev.example.test" } +oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.example.test" } [services.pythoughts_search] base_url = "https://api.dev.example.test/coding/v1/search" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code-env-1234", oauth_host = "https://auth.dev.example.test" } +oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.example.test" } `; const config = parseConfigString(toml, configPath); - expect(config.providers['managed:pythinker-code']?.oauth).toEqual({ + expect(config.providers['managed:kimi-code']?.oauth).toEqual({ storage: 'file', - key: 'oauth/pythinker-code-env-1234', + key: 'oauth/kimi-code-env-1234', oauthHost: 'https://auth.dev.example.test', }); expect(config.services?.pythoughtsSearch?.oauth?.oauthHost).toBe('https://auth.dev.example.test'); @@ -278,7 +278,7 @@ oauth = { storage = "file", key = "oauth/pythinker-code-env-1234", oauth_host = const text = await readFile(configPath, 'utf-8'); expect(text).toContain('oauth_host = "https://auth.dev.example.test"'); const roundTripped = parseConfigString(text, configPath); - expect(roundTripped.providers['managed:pythinker-code']?.oauth?.oauthHost).toBe( + expect(roundTripped.providers['managed:kimi-code']?.oauth?.oauthHost).toBe( 'https://auth.dev.example.test', ); }); @@ -576,7 +576,7 @@ describe('harness config schema and patch merge', () => { const base = parseConfigString(COMPLETE_TOML); const merged = mergeConfigPatch(base, { providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { apiKey: 'sk-patched', baseUrl: undefined, }, @@ -591,14 +591,14 @@ describe('harness config schema and patch merge', () => { }, }); - expect(merged.providers['managed:pythinker-code']).toMatchObject({ + expect(merged.providers['managed:kimi-code']).toMatchObject({ type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', apiKey: 'sk-patched', env: { GOOGLE_CLOUD_PROJECT: 'project-1' }, }); expect(merged.models?.['pythinker-code/pythinker-for-coding']).toMatchObject({ - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['tool_use'], diff --git a/packages/agent-core/test/harness/model-alias-session.test.ts b/packages/agent-core/test/harness/model-alias-session.test.ts index 9a85d75b..1ab499d0 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -24,13 +24,13 @@ import { const CONFIG = ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" api_key = "test-key" base_url = "https://api.example/v1" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 1000000 `; @@ -311,7 +311,7 @@ reason = "no rm" ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" api_key = "test-key" base_url = "https://api.example/v1" diff --git a/packages/agent-core/test/harness/plan-mode-session.test.ts b/packages/agent-core/test/harness/plan-mode-session.test.ts index ee8d05ab..54bb0013 100644 --- a/packages/agent-core/test/harness/plan-mode-session.test.ts +++ b/packages/agent-core/test/harness/plan-mode-session.test.ts @@ -9,13 +9,13 @@ import { createRPC, PythinkerCore, type CoreAPI, type SDKAPI } from '../../src'; const BASE_CONFIG = ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" api_key = "test-key" base_url = "https://api.example/v1" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 1000000 `; diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index aacc898e..abbb9026 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -34,7 +34,7 @@ function resolveRuntimeProvider(input: { const BASE_CONFIG: PythinkerConfig = { defaultModel: 'pythinker-code/pythinker-for-coding', providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', apiKey: 'test-key', baseUrl: 'https://api.example/v1', @@ -42,7 +42,7 @@ const BASE_CONFIG: PythinkerConfig = { }, models: { 'pythinker-code/pythinker-for-coding': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 1_000_000, capabilities: ['thinking', 'image_in', 'video_in', 'tool_use'], @@ -115,11 +115,11 @@ describe('resolveRuntimeProvider model metadata', () => { config: { ...BASE_CONFIG, providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', apiKey: '', baseUrl: 'https://api.example/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, }, @@ -140,7 +140,7 @@ describe('resolveRuntimeProvider model metadata', () => { ...BASE_CONFIG, models: { 'pythinker-code/pythinker-for-coding': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 1_000_000, }, @@ -212,7 +212,7 @@ describe('resolveRuntimeProvider model metadata', () => { ...BASE_CONFIG, models: { broken: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', capabilities: ['thinking'], }, @@ -443,11 +443,11 @@ describe('resolveRuntimeProvider maxOutputSize forwarding', () => { config: { defaultModel: 'managed-fable', providers: { - 'managed:pythinker-code': { type: 'pythinker', apiKey: 'test-key' }, + 'managed:kimi-code': { type: 'pythinker', apiKey: 'test-key' }, }, models: { 'managed-fable': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'claude-fable-5', maxContextSize: 200000, }, @@ -499,7 +499,7 @@ describe('resolveRuntimeProvider Pythinker request headers', () => { config: { ...BASE_CONFIG, providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', apiKey: 'test-key', baseUrl: 'https://api.example/v1', @@ -550,7 +550,7 @@ describe('resolveRuntimeProvider Pythinker request headers', () => { config: { ...BASE_CONFIG, providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', apiKey: 'test-key', baseUrl: 'https://api.example/v1', @@ -856,11 +856,11 @@ describe('ProviderManager OAuth auth', () => { return { ...BASE_CONFIG, providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', apiKey: '', baseUrl: 'https://api.example/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, }; @@ -870,8 +870,8 @@ describe('ProviderManager OAuth auth', () => { 'rejects an API key environment reference with OAuth when its value is %#', (value) => { const config = oauthConfig(); - config.providers['managed:pythinker-code'] = { - ...config.providers['managed:pythinker-code']!, + config.providers['managed:kimi-code'] = { + ...config.providers['managed:kimi-code']!, apiKeyEnvVar: 'OAUTH_CONFLICT_KEY', }; const manager = new ProviderManager({ diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index 97ce22cf..0320fa01 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -686,7 +686,7 @@ custom_headers = { "X-Test" = "1" } const created = await rpc.createSession({ id: 'ses_runtime_service_oauth', workDir }); const session = core.sessions.get(created.id); - expect(resolveOAuthTokenProvider).toHaveBeenCalledWith('managed:pythinker-code', { + expect(resolveOAuthTokenProvider).toHaveBeenCalledWith('managed:kimi-code', { storage: 'file', key: 'oauth/custom-pythinker-code', }); diff --git a/packages/agent-core/test/loop/retry.test.ts b/packages/agent-core/test/loop/retry.test.ts index d1f07423..27839d5e 100644 --- a/packages/agent-core/test/loop/retry.test.ts +++ b/packages/agent-core/test/loop/retry.test.ts @@ -89,7 +89,7 @@ describe('chatWithRetry: terminated stream drops', () => { tokenCalls += 1; throw new PythinkerError( ErrorCodes.PROVIDER_CONNECTION_ERROR, - 'OAuth provider "managed:pythinker-code" failed to fetch an access token: fetch failed', + 'OAuth provider "managed:kimi-code" failed to fetch an access token: fetch failed', ); }, }), @@ -147,16 +147,16 @@ function oauthConfig(): PythinkerConfig { return { defaultModel: 'pythinker-code/pythinker-for-coding', providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', apiKey: '', baseUrl: 'https://api.example/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { 'pythinker-code/pythinker-for-coding': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 1_000_000, }, diff --git a/packages/agent-core/test/rpc/plugins-rpc.test.ts b/packages/agent-core/test/rpc/plugins-rpc.test.ts index f6f78234..6589f20f 100644 --- a/packages/agent-core/test/rpc/plugins-rpc.test.ts +++ b/packages/agent-core/test/rpc/plugins-rpc.test.ts @@ -123,11 +123,11 @@ describe('PythinkerCore plugin RPCs', () => { await writeFile( path.join(home, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.dev.example.test/coding/v1" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code-env-1234", oauth_host = "https://auth.dev.example.test" } +oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.example.test" } `, 'utf8', ); diff --git a/packages/agent-core/test/services/coreProcessService.test.ts b/packages/agent-core/test/services/coreProcessService.test.ts index bff7414b..e5ec0e09 100644 --- a/packages/agent-core/test/services/coreProcessService.test.ts +++ b/packages/agent-core/test/services/coreProcessService.test.ts @@ -245,7 +245,7 @@ describe('CoreProcessService direct construction', () => { it('default-wires a resolveOAuthTokenProvider when caller omits one', () => { const resolver = CoreProcessService._defaultOAuthTokenResolver(tmpHome, join(tmpHome, 'config.toml')); expect(typeof resolver).toBe('function'); - const tokenProvider = resolver('managed:pythinker-code'); + const tokenProvider = resolver('managed:kimi-code'); expect(tokenProvider).toBeDefined(); expect(typeof tokenProvider?.getAccessToken).toBe('function'); }); diff --git a/packages/agent-core/test/services/model-catalog-service.test.ts b/packages/agent-core/test/services/model-catalog-service.test.ts index f6ab6c56..9fc1de54 100644 --- a/packages/agent-core/test/services/model-catalog-service.test.ts +++ b/packages/agent-core/test/services/model-catalog-service.test.ts @@ -7,7 +7,7 @@ import type { PythinkerConfigPatch, SetPythinkerConfigPayload, } from '../../src'; -import { PYTHINKER_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; +import { KIMI_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; import { type ICoreProcessService, @@ -248,18 +248,18 @@ describe('ModelCatalogService', () => { const configRef: { current: PythinkerConfig } = { current: { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', baseUrl: 'https://api.example.test/coding/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', defaultThinking: false, models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 131_072, capabilities: ['thinking'], @@ -284,16 +284,16 @@ describe('ModelCatalogService', () => { const svc = ModelCatalogService._createForTest(makeEnv(), core, authFacade()); await expect(svc.refreshOAuthProviderModels()).resolves.toMatchObject({ - changed: [{ provider_id: PYTHINKER_CODE_PROVIDER_NAME, added: 0, removed: 0 }], + changed: [{ provider_id: KIMI_CODE_PROVIDER_NAME, added: 0, removed: 0 }], failed: [], }); - expect(removeCalls).toEqual([PYTHINKER_CODE_PROVIDER_NAME]); + expect(removeCalls).toEqual([KIMI_CODE_PROVIDER_NAME]); expect(setCalls.at(-1)).toMatchObject({ - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', defaultThinking: true, models: { - 'pythinker-code/pythinker-for-coding': { + 'kimi-code/pythinker-for-coding': { capabilities: ['thinking', 'always_thinking', 'tool_use'], maxContextSize: 262_144, }, diff --git a/packages/agent-core/test/services/oauth-service.test.ts b/packages/agent-core/test/services/oauth-service.test.ts index a69c0328..a6d5ce1a 100644 --- a/packages/agent-core/test/services/oauth-service.test.ts +++ b/packages/agent-core/test/services/oauth-service.test.ts @@ -79,7 +79,7 @@ function makeMockFacade(): MockFacade { }), logout: vi.fn(async (providerName: string | undefined) => { logoutCalls.push({ providerName }); - return { providerName: providerName ?? 'managed:pythinker-code', ok: true as const }; + return { providerName: providerName ?? 'managed:kimi-code', ok: true as const }; }), } as unknown as ServicesAuthFacade; @@ -136,7 +136,7 @@ describe('OAuthService.startLogin', () => { expect(start.user_code).toBe(auth.userCode); expect(start.expires_in).toBe(900); expect(start.interval).toBe(5); - expect(start.provider).toBe('managed:pythinker-code'); + expect(start.provider).toBe('managed:kimi-code'); }); it('falls back to 15-min expires_in when the OAuth host omits the field', async () => { @@ -191,7 +191,7 @@ describe('OAuthService — terminal transitions', () => { await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); await startPromise; - mock.loginCalls[0]!.resolve({ providerName: 'managed:pythinker-code', ok: true }); + mock.loginCalls[0]!.resolve({ providerName: 'managed:kimi-code', ok: true }); await flushMicrotasks(); expect(impl.getFlow()!.status).toBe('authenticated'); @@ -278,7 +278,7 @@ describe('OAuthService.cancelLogin', () => { await flushMicrotasks(); await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); await startPromise; - mock.loginCalls[0]!.resolve({ providerName: 'managed:pythinker-code', ok: true }); + mock.loginCalls[0]!.resolve({ providerName: 'managed:kimi-code', ok: true }); await flushMicrotasks(); const result = await impl.cancelLogin(); @@ -319,7 +319,7 @@ describe('OAuthService.logout', () => { it('delegates to facade.logout and returns logged_out=true', async () => { const { impl, mock } = makeImpl(); const result = await impl.logout(); - expect(result).toEqual({ logged_out: true, provider: 'managed:pythinker-code' }); + expect(result).toEqual({ logged_out: true, provider: 'managed:kimi-code' }); expect(mock.logoutCalls).toHaveLength(1); }); diff --git a/packages/agent-core/test/skill/scanner.test.ts b/packages/agent-core/test/skill/scanner.test.ts index 4143ec42..c1695859 100644 --- a/packages/agent-core/test/skill/scanner.test.ts +++ b/packages/agent-core/test/skill/scanner.test.ts @@ -4,7 +4,13 @@ import path from 'pathe'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { discoverSkills, resolveSkillRoots, SessionSkillRegistry, type SkillRoot } from '../../src/skill'; +import { + discoverSkills, + listWorkspaceSkills, + resolveSkillRoots, + SessionSkillRegistry, + type SkillRoot, +} from '../../src/skill'; const tempDirs: string[] = []; @@ -1381,3 +1387,43 @@ async function writeSkill( await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, lines.join('\n')); } + +describe('listWorkspaceSkills', () => { + it('resolves a workspace catalog with no session, and projects summaries', async () => { + const { homeDir, repoDir, workDir } = await makeWorkspace(); + await writeSkill(path.join(repoDir, '.pythinker-code', 'skills'), path.join('deploy', 'SKILL.md'), [ + '---', + 'name: deploy', + 'description: Ship the service.', + '---', + '', + 'Body.', + ]); + + const skills = await listWorkspaceSkills({ workDir, userHomeDir: homeDir, brandHomeDir: homeDir }); + + const deploy = skills.find((skill) => skill.name === 'deploy'); + expect(deploy).toBeDefined(); + expect(deploy?.description).toBe('Ship the service.'); + // Built-ins are registered too, so the catalog a panel shows is never just project skills. + expect(skills.length).toBeGreaterThan(1); + }); + + it('reports an unreadable skill through onWarning instead of throwing', async () => { + const { homeDir, repoDir, workDir } = await makeWorkspace(); + await writeSkill(path.join(repoDir, '.pythinker-code', 'skills'), path.join('broken', 'SKILL.md'), [ + 'no frontmatter here', + ]); + const warnings: string[] = []; + + const skills = await listWorkspaceSkills({ + workDir, + userHomeDir: homeDir, + brandHomeDir: homeDir, + onWarning: (message) => warnings.push(message), + }); + + expect(skills.find((skill) => skill.name === 'broken')).toBeUndefined(); + expect(warnings.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/migration-legacy/test/steps/config.test.ts b/packages/migration-legacy/test/steps/config.test.ts index 115638fb..9c24507b 100644 --- a/packages/migration-legacy/test/steps/config.test.ts +++ b/packages/migration-legacy/test/steps/config.test.ts @@ -34,17 +34,17 @@ base_url = "https://internal.example.com/v1" api_key = "EMPTY" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.pythinker.com/coding/v1" -[providers."managed:pythinker-code".oauth] +[providers."managed:kimi-code".oauth] storage = "file" -key = "oauth/pythinker-code" +key = "oauth/kimi-code" `; describe('migrateConfigStep', () => { @@ -84,20 +84,20 @@ describe('migrateConfigStep', () => { it('reports a provider conflict and keeps the target provider', async () => { await writeFile( join(src, 'config.toml'), - `[providers."managed:pythinker-code"] + `[providers."managed:kimi-code"] type = "pythinker" base_url = "https://source.example/v1" `, ); await writeFile( join(tgt, 'config.toml'), - `[providers."managed:pythinker-code"] + `[providers."managed:kimi-code"] type = "pythinker" base_url = "https://target.example/v1" `, ); const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); - expect(r.configConflicts).toContain('providers.managed:pythinker-code'); + expect(r.configConflicts).toContain('providers.managed:kimi-code'); const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); expect(cfg).toContain('https://target.example/v1'); expect(cfg).not.toContain('https://source.example/v1'); @@ -154,20 +154,20 @@ base_url = "https://target.example/v1" }); it('drops a kept-provider model missing required schema fields', async () => { - // `bad-model` references the kept `managed:pythinker-code` provider but omits + // `bad-model` references the kept `managed:kimi-code` provider but omits // `max_context_size`, which pythinker-code's ModelAliasSchema requires. Written // verbatim it would make getConfig() reject the whole config post-migration. - const cfg = `[providers."managed:pythinker-code"] + const cfg = `[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.pythinker.com/coding/v1" [models."good-model"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 [models."bad-model"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" `; await writeFile(join(src, 'config.toml'), cfg); @@ -207,13 +207,13 @@ model = "pythinker-for-coding" }); it('drops a model whose provider has no entry anywhere', async () => { - const cfg = `[providers."managed:pythinker-code"] + const cfg = `[providers."managed:kimi-code"] type = "pythinker" api_key = "k" base_url = "https://api.example/v1" [models."good"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "m" max_context_size = 1000 @@ -254,7 +254,7 @@ max_context_size = 1000 await writeFile( join(tgt, 'config.toml'), `[models."target-only"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "m" max_context_size = 1000 `, @@ -270,12 +270,12 @@ max_context_size = 1000 it('drops a migrated model whose provider conflicts with a differing target provider', async () => { await writeFile( join(src, 'config.toml'), - `[providers."managed:pythinker-code"] + `[providers."managed:kimi-code"] type = "pythinker" base_url = "https://legacy.example/v1" [models."conflicted"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "m" max_context_size = 1000 `, @@ -285,13 +285,13 @@ max_context_size = 1000 // to the wrong backend. await writeFile( join(tgt, 'config.toml'), - `[providers."managed:pythinker-code"] + `[providers."managed:kimi-code"] type = "pythinker" base_url = "https://target.example/v1" `, ); const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); - expect(r.configConflicts).toContain('providers.managed:pythinker-code'); + expect(r.configConflicts).toContain('providers.managed:kimi-code'); expect(r.droppedModels).toContain('conflicted'); const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); expect(cfg).not.toContain('conflicted'); diff --git a/packages/node-sdk/examples/pythinker-harness-auth-smoke.ts b/packages/node-sdk/examples/pythinker-harness-auth-smoke.ts index d8cebeec..fe280ed6 100644 --- a/packages/node-sdk/examples/pythinker-harness-auth-smoke.ts +++ b/packages/node-sdk/examples/pythinker-harness-auth-smoke.ts @@ -6,7 +6,7 @@ import { createPythinkerHarness, type PythinkerHarness } from '@pythoughts/pythi import { smokeIdentityFromEnv, runPromptToEnd } from './runtime-smoke-helpers'; -const MANAGED_PYTHINKER_CODE_PROVIDER = 'managed:pythinker-code'; +const MANAGED_KIMI_CODE_PROVIDER = 'managed:kimi-code'; async function main(): Promise { const explicitHomeDir = process.env['PYTHINKER_SDK_AUTH_SMOKE_HOME']; @@ -24,14 +24,14 @@ async function main(): Promise { try { if (forceLogin) { - await harness.auth.logout(MANAGED_PYTHINKER_CODE_PROVIDER); + await harness.auth.logout(MANAGED_KIMI_CODE_PROVIDER); process.stdout.write('cleared existing smoke token\n'); } const login = await harness.auth.login(undefined, { onDeviceCode: printDeviceCode }); const config = await harness.getConfig({ reload: true }); - const status = await harness.auth.status(MANAGED_PYTHINKER_CODE_PROVIDER); - const usage = await harness.auth.getManagedUsage(MANAGED_PYTHINKER_CODE_PROVIDER); + const status = await harness.auth.status(MANAGED_KIMI_CODE_PROVIDER); + const usage = await harness.auth.getManagedUsage(MANAGED_KIMI_CODE_PROVIDER); if (login.defaultModel === undefined || config.defaultModel === undefined) { throw new Error('login did not provision a default model'); @@ -39,7 +39,7 @@ async function main(): Promise { if (status.providers[0]?.hasToken !== true) { throw new Error('status did not report a stored token after login'); } - if (config.providers[MANAGED_PYTHINKER_CODE_PROVIDER]?.oauth?.key !== 'oauth/pythinker-code') { + if (config.providers[MANAGED_KIMI_CODE_PROVIDER]?.oauth?.key !== 'oauth/kimi-code') { throw new Error('managed provider oauth config was not written'); } @@ -59,7 +59,7 @@ async function main(): Promise { process.stdout.write(`auth smoke passed: ${session.id}\n`); } finally { if (!keepToken) { - await harness.auth.logout(MANAGED_PYTHINKER_CODE_PROVIDER).catch(() => {}); + await harness.auth.logout(MANAGED_KIMI_CODE_PROVIDER).catch(() => {}); } await harness.close(); if (explicitHomeDir === undefined && !keepToken) { diff --git a/packages/node-sdk/examples/pythinker-harness-config-smoke.ts b/packages/node-sdk/examples/pythinker-harness-config-smoke.ts index 6a75346e..53ca7939 100644 --- a/packages/node-sdk/examples/pythinker-harness-config-smoke.ts +++ b/packages/node-sdk/examples/pythinker-harness-config-smoke.ts @@ -21,16 +21,16 @@ async function main(): Promise { defaultPermissionMode: 'manual', defaultPlanMode: false, providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { 'pythinker-code/pythinker-for-coding': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['image_in', 'thinking', 'video_in'], @@ -47,12 +47,12 @@ async function main(): Promise { pythoughtsSearch: { baseUrl: 'https://api.pythinker.com/coding/v1/search', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, pythoughtsFetch: { baseUrl: 'https://api.pythinker.com/coding/v1/fetch', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, }); @@ -62,8 +62,8 @@ async function main(): Promise { for (const expected of [ 'default_model = "pythinker-code/pythinker-for-coding"', 'default_permission_mode = "manual"', - '[providers."managed:pythinker-code"]', - '[providers."managed:pythinker-code".oauth]', + '[providers."managed:kimi-code"]', + '[providers."managed:kimi-code".oauth]', '[models."pythinker-code/pythinker-for-coding"]', '[services.pythoughts_search]', ]) { @@ -76,7 +76,7 @@ async function main(): Promise { if (reloaded.defaultModel !== 'pythinker-code/pythinker-for-coding') { throw new Error('reloaded config did not preserve defaultModel'); } - if (reloaded.providers['managed:pythinker-code']?.oauth?.key !== 'oauth/pythinker-code') { + if (reloaded.providers['managed:kimi-code']?.oauth?.key !== 'oauth/kimi-code') { throw new Error('reloaded config did not preserve provider oauth'); } diff --git a/packages/node-sdk/src/auth.ts b/packages/node-sdk/src/auth.ts index 4613ebc0..baebb229 100644 --- a/packages/node-sdk/src/auth.ts +++ b/packages/node-sdk/src/auth.ts @@ -7,19 +7,19 @@ import { type OAuthRef, } from '@pythoughts/agent-core'; import { - applyManagedPythinkerCodeConfig, - applyManagedPythinkerCodeLogoutConfig, - PYTHINKER_CODE_PROVIDER_NAME, + applyManagedKimiCodeConfig, + applyManagedKimiCodeLogoutConfig, + KIMI_CODE_PROVIDER_NAME, PythinkerOAuthToolkit, - resolvePythinkerCodeLoginAuth, - resolvePythinkerCodeRuntimeAuth, + resolveKimiCodeLoginAuth, + resolveKimiCodeRuntimeAuth, type AuthManagedUsageResult, type AuthStatus, type BearerTokenProvider, type FetchSubmitFeedbackResult, type PythinkerHostIdentity, type PythinkerOAuthLoginOptions, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, type OAuthRefreshOutcome, } from '@pythoughts/pythinker-code-oauth'; @@ -56,7 +56,7 @@ export interface PythinkerAuthFacadeOptions { readonly onRefresh?: ((outcome: OAuthRefreshOutcome) => void) | undefined; } -type SDKManagedConfig = PythinkerConfig & ManagedPythinkerConfigShape; +type SDKManagedConfig = PythinkerConfig & ManagedKimiConfigShape; export class PythinkerAuthFacade { private readonly toolkit: PythinkerOAuthToolkit; @@ -74,8 +74,8 @@ export class PythinkerAuthFacade { write: async (config) => { await writeConfigFile(options.configPath, config); }, - apply: applyManagedPythinkerCodeConfig, - remove: applyManagedPythinkerCodeLogoutConfig, + apply: applyManagedKimiCodeConfig, + remove: applyManagedKimiCodeLogoutConfig, }, }); } @@ -85,11 +85,11 @@ export class PythinkerAuthFacade { } async login( - providerName: string | undefined = PYTHINKER_CODE_PROVIDER_NAME, + providerName: string | undefined = KIMI_CODE_PROVIDER_NAME, options: PythinkerAuthLoginOptions = {}, ): Promise { const auth = this.resolveManagedAuth(providerName); - const loginAuth = resolvePythinkerCodeLoginAuth({ + const loginAuth = resolveKimiCodeLoginAuth({ configuredBaseUrl: auth.baseUrl, configuredOAuthRef: auth.oauthRef, requestedBaseUrl: options.baseUrl, @@ -193,7 +193,7 @@ export class PythinkerAuthFacade { readonly oauthRef?: OAuthRef | undefined; readonly baseUrl?: string | undefined; } { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; // Read path: token/status resolution must work off a degraded config // instead of failing the session when an unrelated section is broken. // Write paths (the toolkit's configAdapter.read) stay strict. @@ -210,7 +210,7 @@ export class PythinkerAuthFacade { readonly baseUrl?: string | undefined; } { const auth = this.resolveManagedAuth(providerName); - return resolvePythinkerCodeRuntimeAuth({ + return resolveKimiCodeRuntimeAuth({ configuredBaseUrl: auth.baseUrl, configuredOAuthRef: auth.oauthRef, }); @@ -220,9 +220,9 @@ export class PythinkerAuthFacade { providerName: string | undefined, oauthRef?: OAuthRef | undefined, ): OAuthRef | undefined { - if ((providerName ?? PYTHINKER_CODE_PROVIDER_NAME) !== PYTHINKER_CODE_PROVIDER_NAME) return oauthRef; + if ((providerName ?? KIMI_CODE_PROVIDER_NAME) !== KIMI_CODE_PROVIDER_NAME) return oauthRef; const auth = this.resolveManagedAuth(providerName); - return resolvePythinkerCodeRuntimeAuth({ + return resolveKimiCodeRuntimeAuth({ configuredBaseUrl: auth.baseUrl, configuredOAuthRef: oauthRef ?? auth.oauthRef, }).oauthRef; diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index 7c6e6120..b55dc889 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -136,3 +136,111 @@ export function applyCatalogProvider( config.defaultThinking = options.thinking; return { defaultModel }; } + +export interface CatalogProviderStore { + ensureConfigFile(): Promise; + getConfig(): Promise; + removeProvider(providerId: string): Promise; + setConfig(patch: Partial): Promise; +} + +export interface ImportCatalogProviderOptions { + readonly providerId: string; + readonly entry: CatalogProviderEntry; + readonly catalogUrl?: string; + /** Literal key, written into config.toml. Mutually exclusive with `apiKeyEnvVar`. */ + readonly apiKey?: string; + /** Name of an environment variable the key is read from at connection time. */ + readonly apiKeyEnvVar?: string; + /** Alias to make the default model, as a bare catalog model id. */ + readonly defaultModel?: string; +} + +export interface ImportCatalogProviderResult { + readonly models: readonly CatalogModel[]; + readonly defaultModel: string | undefined; +} + +export class CatalogProviderError extends Error {} + +/** + * Writes one catalog provider and its models into the persisted config. + * + * Re-importing a configured provider has to drop its stale aliases first, and + * `removeProvider` clears any default pointing at them — so the defaults are + * snapshotted up front and restored afterwards, but only while they still + * resolve against the refreshed catalog. A default left pointing at a model the + * catalog no longer lists would break the next session. + */ +export async function importCatalogProvider( + store: CatalogProviderStore, + options: ImportCatalogProviderOptions, +): Promise { + const { providerId, entry } = options; + const wire = catalogConnectionWire(entry); + if (wire === undefined) { + throw new CatalogProviderError( + `Provider "${providerId}" cannot be configured with a single API key.`, + ); + } + + const apiKey = options.apiKey?.trim(); + const apiKeyEnvVar = options.apiKeyEnvVar?.trim(); + if ((apiKey ?? '').length === 0 && (apiKeyEnvVar ?? '').length === 0) { + throw new CatalogProviderError(`Provider "${providerId}" needs an API key.`); + } + + const models = catalogProviderModels(entry); + if (models.length === 0) { + throw new CatalogProviderError(`Provider "${providerId}" lists no usable models.`); + } + if (options.defaultModel !== undefined && !models.some((m) => m.id === options.defaultModel)) { + throw new CatalogProviderError( + `Model "${options.defaultModel}" is not offered by provider "${providerId}".`, + ); + } + + await store.ensureConfigFile(); + let config = await store.getConfig(); + const previousDefaultProvider = config.defaultProvider; + const previousDefaultModel = config.defaultModel; + const previousDefaultThinking = config.defaultThinking; + + if (config.providers[providerId] !== undefined) { + config = await store.removeProvider(providerId); + } + + const useEnvVar = (apiKey ?? '').length === 0; + applyCatalogProvider(config, { + providerId, + catalogUrl: options.catalogUrl ?? DEFAULT_CATALOG_URL, + wire, + baseUrl: catalogBaseUrl(entry, wire), + apiKey: useEnvVar ? undefined : apiKey, + apiKeyEnvVar: useEnvVar ? apiKeyEnvVar : undefined, + models, + selectedModelId: options.defaultModel ?? '', + thinking: false, + }); + + if (options.defaultModel === undefined) { + const stillResolves = + previousDefaultModel !== undefined && config.models?.[previousDefaultModel] !== undefined; + config.defaultModel = stillResolves ? previousDefaultModel : undefined; + } + config.defaultProvider = + previousDefaultProvider !== undefined && config.providers[previousDefaultProvider] !== undefined + ? previousDefaultProvider + : undefined; + config.defaultThinking = previousDefaultThinking; + + await store.setConfig({ + providers: config.providers, + models: config.models, + defaultProvider: config.defaultProvider, + defaultModel: config.defaultModel, + defaultThinking: config.defaultThinking, + }); + + return { models, defaultModel: config.defaultModel }; +} diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index fd86077e..cb2e3b62 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -28,8 +28,10 @@ export { catalogModelToAlias, catalogProviderModels, CatalogFetchError, + CatalogProviderError, DEFAULT_CATALOG_URL, fetchCatalog, + importCatalogProvider, inferWireType, loadBuiltInCatalog, } from '#/catalog'; @@ -38,8 +40,14 @@ export type { Catalog, CatalogModel, CatalogProviderEntry, + CatalogProviderStore, + ImportCatalogProviderOptions, + ImportCatalogProviderResult, } from '#/catalog'; +export { buildSkillSlashCommands, isUserActivatableSkill } from '#/skill-commands'; +export type { SkillSlashCommand, SkillSlashCommands } from '#/skill-commands'; + export { ErrorCodes, PythinkerError, diff --git a/packages/node-sdk/src/pythinker-code-model-provider.ts b/packages/node-sdk/src/pythinker-code-model-provider.ts index 781387da..91bf40e4 100644 --- a/packages/node-sdk/src/pythinker-code-model-provider.ts +++ b/packages/node-sdk/src/pythinker-code-model-provider.ts @@ -8,13 +8,13 @@ import { } from '@pythoughts/agent-core'; import { createPythinkerDefaultHeaders, - PYTHINKER_CODE_FLOW_CONFIG, - PYTHINKER_CODE_PROVIDER_NAME, + KIMI_CODE_FLOW_CONFIG, + KIMI_CODE_PROVIDER_NAME, PythinkerOAuthToolkit, - pythinkerCodeBaseUrl, - resolvePythinkerCodeOAuthRef, + kimiCodeBaseUrl, + resolveKimiCodeOAuthRef, type PythinkerHostIdentity, - type ManagedPythinkerOAuthRef, + type ManagedKimiOAuthRef, } from '@pythoughts/pythinker-code-oauth'; import type { ProviderConfig as KosongProviderConfig, @@ -40,11 +40,11 @@ export class PythinkerForCodingProvider implements ModelProvider { private readonly toolkit: PythinkerOAuthToolkit; private readonly homeDir: string; private readonly identity: PythinkerHostIdentity; - private readonly oauthRef: ManagedPythinkerOAuthRef; + private readonly oauthRef: ManagedKimiOAuthRef; constructor(options: PythinkerForCodingProviderOptions) { this.model = options.model ?? 'pythinker-for-coding'; - this.baseUrl = options.baseUrl ?? pythinkerCodeBaseUrl(); + this.baseUrl = options.baseUrl ?? kimiCodeBaseUrl(); this.promptCacheKey = options.promptCacheKey; this.defaultHeaders = options.defaultHeaders; this.homeDir = resolvePythinkerHome(options.homeDir); @@ -53,8 +53,8 @@ export class PythinkerForCodingProvider implements ModelProvider { version: options.version, userAgentSuffix: options.userAgentSuffix, }; - this.oauthRef = resolvePythinkerCodeOAuthRef({ - oauthHost: PYTHINKER_CODE_FLOW_CONFIG.oauthHost, + this.oauthRef = resolveKimiCodeOAuthRef({ + oauthHost: KIMI_CODE_FLOW_CONFIG.oauthHost, baseUrl: this.baseUrl, }); this.toolkit = new PythinkerOAuthToolkit({ @@ -122,7 +122,7 @@ export class PythinkerForCodingProvider implements ModelProvider { private async buildAuth(force: boolean): Promise { try { - const apiKey = await this.toolkit.ensureFresh(PYTHINKER_CODE_PROVIDER_NAME, { + const apiKey = await this.toolkit.ensureFresh(KIMI_CODE_PROVIDER_NAME, { force, oauthRef: this.oauthRef, }); @@ -132,7 +132,7 @@ export class PythinkerForCodingProvider implements ModelProvider { // turn surfaces `auth.login_required` / `provider.connection_error` // instead of collapsing everything to `internal`. Unrecognized errors are // rethrown raw (see mapOAuthTokenError). - throw mapOAuthTokenError(error, PYTHINKER_CODE_PROVIDER_NAME) ?? error; + throw mapOAuthTokenError(error, KIMI_CODE_PROVIDER_NAME) ?? error; } } } diff --git a/packages/node-sdk/src/pythinker-harness.ts b/packages/node-sdk/src/pythinker-harness.ts index 48a4b099..b0087f1a 100644 --- a/packages/node-sdk/src/pythinker-harness.ts +++ b/packages/node-sdk/src/pythinker-harness.ts @@ -25,6 +25,7 @@ import type { RenameSessionInput, ResumeSessionInput, SessionSummary, + SkillSummary, TelemetryClient, TelemetryContextPatch, TelemetryProperties, @@ -235,6 +236,11 @@ export class PythinkerHarness { return this.rpc.listAgentProfiles(workDir); } + /** The workspace's skills without opening a session; excludes session-only MCP prompts. */ + async listWorkspaceSkills(workDir: string): Promise { + return this.rpc.listWorkspaceSkills(workDir); + } + async ensureConfigFile(): Promise { await this.ensureConfigFileImpl(); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index dcd13a14..5f8b9318 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -249,6 +249,12 @@ export abstract class SDKRpcClientBase { return rpc.listAgentProfiles({ workDir }); } + /** The workspace's skills without opening a session; excludes session-only MCP prompts. */ + async listWorkspaceSkills(workDir: string): Promise { + const rpc = await this.getRpc(); + return rpc.listWorkspaceSkills({ workDir }); + } + async setConfig(input: PythinkerConfigPatch): Promise { const rpc = await this.getRpc(); return rpc.setPythinkerConfig(input); diff --git a/packages/node-sdk/src/skill-commands.ts b/packages/node-sdk/src/skill-commands.ts new file mode 100644 index 00000000..23cf63e8 --- /dev/null +++ b/packages/node-sdk/src/skill-commands.ts @@ -0,0 +1,64 @@ +import type { SkillSummary } from '@pythoughts/agent-core'; + +export interface SkillSlashCommand { + readonly name: string; + readonly aliases: readonly string[]; + readonly description: string; + readonly argumentHint?: string; +} + +export interface SkillSlashCommands { + readonly commands: readonly SkillSlashCommand[]; + /** Slash command name → skill name, for dispatching an invocation back to the engine. */ + readonly commandMap: ReadonlyMap; +} + +/** A skill a person can invoke by hand, as opposed to one only the model may load. */ +export function isUserActivatableSkill(skill: SkillSummary): boolean { + return ( + skill.userInvocable !== false && + (skill.type === undefined || + skill.type === 'prompt' || + skill.type === 'inline' || + skill.type === 'flow') + ); +} + +/** Built-in skills come first; everything else keeps alphabetical order. */ +function compareSkillSlashCommands(a: SkillSummary, b: SkillSummary): number { + return ( + skillSlashCommandGroup(a.source) - skillSlashCommandGroup(b.source) || + a.name.localeCompare(b.name) + ); +} + +function skillSlashCommandGroup(source: SkillSummary['source']): number { + return source === 'builtin' ? 0 : 1; +} + +/** + * Projects a skill catalog into slash commands. Built-in skills and sub-skills + * keep their bare name; everything else is namespaced under `skill:` so a + * workspace skill cannot shadow a host command. + */ +export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillSlashCommands { + const commandMap = new Map(); + const commands = [...skills] + .toSorted(compareSkillSlashCommands) + .filter(isUserActivatableSkill) + .map((skill) => { + const commandName = + skill.commandName ?? + (skill.source === 'builtin' || skill.isSubSkill === true + ? skill.name + : `skill:${skill.name}`); + commandMap.set(commandName, skill.name); + return { + name: commandName, + aliases: [] as readonly string[], + description: skill.description ?? '', + argumentHint: skill.argumentHint, + }; + }); + return { commands, commandMap }; +} diff --git a/packages/node-sdk/test/auth-facade.test.ts b/packages/node-sdk/test/auth-facade.test.ts index 202d2a3a..5a371363 100644 --- a/packages/node-sdk/test/auth-facade.test.ts +++ b/packages/node-sdk/test/auth-facade.test.ts @@ -4,12 +4,12 @@ import { join } from 'node:path'; import { FileTokenStorage, - PYTHINKER_CODE_PROVIDER_NAME, + KIMI_CODE_PROVIDER_NAME, PythinkerOAuthToolkit, OAuthConnectionError, OAuthError, RetryableRefreshError, - resolvePythinkerCodeOAuthKey, + resolveKimiCodeOAuthKey, resolvePythinkerTokenStorageName, type TokenInfo, } from '@pythoughts/pythinker-code-oauth'; @@ -60,7 +60,7 @@ describe('PythinkerHarness.auth', () => { }); it('exposes a cached access token without refreshing auth state', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token'); @@ -70,7 +70,7 @@ describe('PythinkerHarness.auth', () => { const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect( - harness.auth.resolveOAuthTokenProvider(PYTHINKER_CODE_PROVIDER_NAME).getAccessToken(), + harness.auth.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(), ).rejects.toMatchObject({ code: ErrorCodes.AUTH_LOGIN_REQUIRED, }); @@ -94,7 +94,7 @@ describe('PythinkerHarness.auth', () => { const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); const error = await harness.auth - .resolveOAuthTokenProvider(PYTHINKER_CODE_PROVIDER_NAME) + .resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME) .getAccessToken() .catch((error: unknown) => error); @@ -123,7 +123,7 @@ describe('PythinkerHarness.auth', () => { const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect( - harness.auth.resolveOAuthTokenProvider(PYTHINKER_CODE_PROVIDER_NAME).getAccessToken(), + harness.auth.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(), ).rejects.toBe(oauthError); } finally { tokenProviderSpy.mockRestore(); @@ -131,11 +131,11 @@ describe('PythinkerHarness.auth', () => { }); it('resolves managed auth from a partially invalid config without throwing', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" api_key = "" @@ -149,23 +149,23 @@ max_steps_per_turn = "abc" // config.toml must degrade, not break OAuth-backed sessions. await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token'); await expect(harness.auth.status()).resolves.toMatchObject({ - providers: [{ providerName: PYTHINKER_CODE_PROVIDER_NAME, hasToken: true }], + providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], }); }); it('resolves cached access tokens from the configured scoped OAuth ref', async () => { - const oauthKey = resolvePythinkerCodeOAuthKey({ + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl: 'https://api.dev.example.test/coding/v1', }); const storageName = resolvePythinkerTokenStorageName({ oauthKey }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save('pythinker-code', freshToken()); + await storage.save('kimi-code', freshToken()); await storage.save(storageName, { ...freshToken(), accessToken: 'dev-access-token' }); await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.dev.example.test/coding/v1" api_key = "" @@ -178,7 +178,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. }); it('reports auth status from the configured scoped OAuth ref', async () => { - const oauthKey = resolvePythinkerCodeOAuthKey({ + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl: 'https://api.dev.example.test/coding/v1', }); @@ -189,7 +189,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.dev.example.test/coding/v1" api_key = "" @@ -199,12 +199,12 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.status()).resolves.toEqual({ - providers: [{ providerName: PYTHINKER_CODE_PROVIDER_NAME, hasToken: true }], + providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], }); }); it('provisions SDK config using an existing Pythinker OAuth token', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); const fetchMock = vi.fn( async (_input, _init) => new Response( @@ -230,9 +230,9 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. const config = await harness.getConfig({ reload: true }); expect(result).toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', defaultThinking: true, }); expect(fetchMock).toHaveBeenCalledWith( @@ -243,8 +243,8 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. }), }), ); - expect(config.defaultModel).toBe('pythinker-code/pythinker-for-coding'); - expect(config.models?.['pythinker-code/pythinker-for-coding']).toMatchObject({ + expect(config.defaultModel).toBe('kimi-code/pythinker-for-coding'); + expect(config.models?.['kimi-code/pythinker-for-coding']).toMatchObject({ capabilities: ['thinking', 'image_in', 'video_in', 'tool_use'], displayName: 'Pythinker for Coding', }); @@ -253,21 +253,21 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. tool_use: true, }, }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ type: 'pythinker', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }); expect(config.services?.pythoughtsSearch?.oauth).toEqual({ storage: 'file', - key: 'oauth/pythinker-code', + key: 'oauth/kimi-code', }); }); it('logs in against the configured scoped OAuth host and base URL when env is absent', async () => { const baseUrl = 'https://api.dev.example.test/coding/v1'; const oauthHost = 'https://auth.dev.example.test'; - const oauthKey = resolvePythinkerCodeOAuthKey({ oauthHost, baseUrl }); + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost, baseUrl }); const storageName = resolvePythinkerTokenStorageName({ oauthKey }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); await storage.save(storageName, { @@ -279,7 +279,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "${baseUrl}" api_key = "" @@ -325,9 +325,9 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "${oauthHost}" } const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.login()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', }); expect(refreshBody?.get('grant_type')).toBe('refresh_token'); expect(refreshBody?.get('refresh_token')).toBe('dev-refresh-token'); @@ -336,7 +336,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "${oauthHost}" } accessToken: 'rotated-dev-access-token', }); const config = await harness.getConfig({ reload: true }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ baseUrl, oauth: { storage: 'file', key: oauthKey, oauthHost }, }); @@ -348,10 +348,10 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "${oauthHost}" } it('recomputes legacy managed OAuth refs during login for non-default base URLs', async () => { const baseUrl = 'https://api.example.test/coding/v1'; - const oauthKey = resolvePythinkerCodeOAuthKey({ baseUrl }); + const oauthKey = resolveKimiCodeOAuthKey({ baseUrl }); const scopedStorageName = resolvePythinkerTokenStorageName({ oauthKey }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save('pythinker-code', { ...freshToken(), accessToken: 'legacy-access-token' }); + await storage.save('kimi-code', { ...freshToken(), accessToken: 'legacy-access-token' }); await storage.save(scopedStorageName, { ...freshToken(), accessToken: 'scoped-access-token', @@ -359,11 +359,11 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "${oauthHost}" } await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "${baseUrl}" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code" } +oauth = { storage = "file", key = "oauth/kimi-code" } `, ); const fetchMock = vi.fn(async (input, init) => { @@ -386,14 +386,14 @@ oauth = { storage = "file", key = "oauth/pythinker-code" } const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.login()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', }); expect(fetchMock).toHaveBeenCalledTimes(1); const config = await harness.getConfig({ reload: true }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ baseUrl, oauth: { storage: 'file', key: oauthKey, oauthHost: 'https://auth.kimi.com' }, }); @@ -403,8 +403,8 @@ oauth = { storage = "file", key = "oauth/pythinker-code" } const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; const envBaseUrl = 'https://api.env.example.test/coding/v1'; const envOauthHost = 'https://auth.env.example.test'; - const configuredOauthKey = resolvePythinkerCodeOAuthKey({ baseUrl: configuredBaseUrl }); - const envOauthKey = resolvePythinkerCodeOAuthKey({ oauthHost: envOauthHost, baseUrl: envBaseUrl }); + const configuredOauthKey = resolveKimiCodeOAuthKey({ baseUrl: configuredBaseUrl }); + const envOauthKey = resolveKimiCodeOAuthKey({ oauthHost: envOauthHost, baseUrl: envBaseUrl }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); await storage.save(resolvePythinkerTokenStorageName({ oauthKey: configuredOauthKey }), { ...freshToken(), @@ -417,7 +417,7 @@ oauth = { storage = "file", key = "oauth/pythinker-code" } await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "${configuredBaseUrl}" api_key = "" @@ -446,32 +446,32 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.login()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', }); expect(fetchMock).toHaveBeenCalledTimes(1); const config = await harness.getConfig({ reload: true }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ baseUrl: envBaseUrl, oauth: { storage: 'file', key: envOauthKey, oauthHost: envOauthHost }, }); }); it('starts degraded when a configured model alias does not have max_context_size', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); await writeFile( join(homeDir, 'config.toml'), ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" api_key = "" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" `, ); @@ -500,30 +500,30 @@ model = "pythinker-for-coding" // dropped, the rest of the config survives, and a warning is reported. const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); const config = await harness.getConfig(); - expect(config.models?.['pythinker-code/pythinker-for-coding']).toBeUndefined(); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toBeDefined(); + expect(config.models?.['kimi-code/pythinker-for-coding']).toBeUndefined(); + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeDefined(); const { warnings } = await harness.getConfigDiagnostics(); expect(warnings.some((w) => w.includes('models.pythinker-code/pythinker-for-coding'))).toBe(true); }); it('removes managed Pythinker config on logout', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); await writeFile( join(homeDir, 'config.toml'), ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code" } +oauth = { storage = "file", key = "oauth/kimi-code" } [providers.custom] type = "pythinker" api_key = "sk-existing" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 @@ -535,62 +535,62 @@ max_context_size = 1000 [services.pythoughts_search] base_url = "https://api.pythinker.com/coding/v1/search" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code" } +oauth = { storage = "file", key = "oauth/kimi-code" } [services.pythoughts_fetch] base_url = "https://api.pythinker.com/coding/v1/fetch" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code" } +oauth = { storage = "file", key = "oauth/kimi-code" } `, ); const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.logout()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, }); const config = await harness.getConfig({ reload: true }); expect(config.defaultModel).toBeUndefined(); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toBeUndefined(); + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeUndefined(); expect(config.providers['custom']).toMatchObject({ apiKey: 'sk-existing' }); - expect(config.models?.['pythinker-code/pythinker-for-coding']).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-for-coding']).toBeUndefined(); expect(config.models?.['custom-default']).toMatchObject({ provider: 'custom' }); expect(config.services?.pythoughtsSearch).toBeUndefined(); expect(config.services?.pythoughtsFetch).toBeUndefined(); await expect( - new FileTokenStorage(join(homeDir, 'credentials')).load('pythinker-code'), + new FileTokenStorage(join(homeDir, 'credentials')).load('kimi-code'), ).resolves.toBeUndefined(); const text = await readFile(join(homeDir, 'config.toml'), 'utf-8'); - expect(text).not.toContain('managed:pythinker-code'); - expect(text).not.toContain('pythinker-code/pythinker-for-coding'); + expect(text).not.toContain('managed:kimi-code'); + expect(text).not.toContain('kimi-code/pythinker-for-coding'); expect(text).not.toContain('pythoughts_search'); }); it('removes the configured scoped OAuth token on logout without touching the production token', async () => { - const oauthKey = resolvePythinkerCodeOAuthKey({ + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl: 'https://api.dev.example.test/coding/v1', }); const storageName = resolvePythinkerTokenStorageName({ oauthKey }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save('pythinker-code', freshToken()); + await storage.save('kimi-code', freshToken()); await storage.save(storageName, { ...freshToken(), accessToken: 'dev-access-token' }); await writeFile( join(homeDir, 'config.toml'), ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.dev.example.test/coding/v1" api_key = "" oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" } [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 `, @@ -598,22 +598,22 @@ max_context_size = 262144 const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.logout()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, }); await expect(storage.load(storageName)).resolves.toBeUndefined(); - await expect(storage.load('pythinker-code')).resolves.toMatchObject({ + await expect(storage.load('kimi-code')).resolves.toMatchObject({ accessToken: 'oauth-access-token', }); }); it('recomputes legacy managed OAuth refs during logout for non-default base URLs', async () => { const baseUrl = 'https://api.example.test/coding/v1'; - const oauthKey = resolvePythinkerCodeOAuthKey({ baseUrl }); + const oauthKey = resolveKimiCodeOAuthKey({ baseUrl }); const scopedStorageName = resolvePythinkerTokenStorageName({ oauthKey }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save('pythinker-code', freshToken()); + await storage.save('kimi-code', freshToken()); await storage.save(scopedStorageName, { ...freshToken(), accessToken: 'scoped-access-token', @@ -623,14 +623,14 @@ max_context_size = 262144 ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "${baseUrl}" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code" } +oauth = { storage = "file", key = "oauth/kimi-code" } [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 `, @@ -638,18 +638,18 @@ max_context_size = 262144 const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.logout()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, }); await expect(storage.load(scopedStorageName)).resolves.toBeUndefined(); - await expect(storage.load('pythinker-code')).resolves.toMatchObject({ + await expect(storage.load('kimi-code')).resolves.toMatchObject({ accessToken: 'oauth-access-token', }); }); it('gets managed usage without host identity and sends only auth headers', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); const fetchMock = vi.fn( async (_input, _init) => new Response( @@ -678,7 +678,7 @@ max_context_size = 262144 it('uses configured scoped OAuth refs and base URLs for managed usage and feedback', async () => { const baseUrl = 'https://api.dev.example.test/coding/v1'; - const oauthKey = resolvePythinkerCodeOAuthKey({ + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl, }); @@ -690,7 +690,7 @@ max_context_size = 262144 await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "${baseUrl}" api_key = "" @@ -720,7 +720,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. sessionId: 'sess-dev', version: 'pythinker-code-0.1.1', os: 'Darwin 25.3.0', - model: 'pythinker-code/pythinker-for-coding', + model: 'kimi-code/pythinker-for-coding', }), ).resolves.toEqual({ kind: 'ok' }); @@ -736,8 +736,8 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; const envBaseUrl = 'https://api.env.example.test/coding/v1'; const envOauthHost = 'https://auth.env.example.test'; - const configuredOauthKey = resolvePythinkerCodeOAuthKey({ baseUrl: configuredBaseUrl }); - const envOauthKey = resolvePythinkerCodeOAuthKey({ + const configuredOauthKey = resolveKimiCodeOAuthKey({ baseUrl: configuredBaseUrl }); + const envOauthKey = resolveKimiCodeOAuthKey({ oauthHost: envOauthHost, baseUrl: envBaseUrl, }); @@ -753,7 +753,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "${configuredBaseUrl}" api_key = "" @@ -776,15 +776,15 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ const harness = createPythinkerHarness({ homeDir }); await expect(harness.auth.status()).resolves.toEqual({ - providers: [{ providerName: PYTHINKER_CODE_PROVIDER_NAME, hasToken: true }], + providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], }); await expect(harness.auth.getCachedAccessToken()).resolves.toBe('env-access-token'); await expect( - harness.auth.resolveOAuthTokenProvider(PYTHINKER_CODE_PROVIDER_NAME).getAccessToken(), + harness.auth.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(), ).resolves.toBe('env-access-token'); await expect( harness.auth - .resolveOAuthTokenProvider(PYTHINKER_CODE_PROVIDER_NAME, { + .resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME, { storage: 'file', key: configuredOauthKey, oauthHost: 'https://auth.pythinker.com', @@ -801,7 +801,7 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ sessionId: 'sess-env', version: 'pythinker-code-0.1.1', os: 'Darwin 25.3.0', - model: 'pythinker-code/pythinker-for-coding', + model: 'kimi-code/pythinker-for-coding', }), ).resolves.toEqual({ kind: 'ok' }); @@ -813,7 +813,7 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ }); it('submitFeedback maps camelCase input to snake_case body and posts with bearer auth', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); const fetchMock = vi.fn(async () => new Response('', { status: 200 })); vi.stubGlobal('fetch', fetchMock); @@ -823,7 +823,7 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ sessionId: 'sess-42', version: 'pythinker-code-0.1.1', os: 'Darwin 25.3.0', - model: 'pythinker-code/pythinker-for-coding', + model: 'kimi-code/pythinker-for-coding', }); expect(result).toEqual({ kind: 'ok' }); @@ -842,12 +842,12 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ content: 'great tool', version: 'pythinker-code-0.1.1', os: 'Darwin 25.3.0', - model: 'pythinker-code/pythinker-for-coding', + model: 'kimi-code/pythinker-for-coding', }); }); it('submitFeedback surfaces HTTP errors without throwing', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); vi.stubGlobal( 'fetch', vi.fn( diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index df69df1c..411890af 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -8,6 +8,7 @@ import { CatalogFetchError, DEFAULT_CATALOG_URL, fetchCatalog, + importCatalogProvider, type CatalogModel, } from '../src/catalog'; @@ -192,3 +193,123 @@ describe('applyCatalogProvider', () => { expect(config.models?.['other/keep']).toBeDefined(); }); }); + +describe('importCatalogProvider', () => { + function createStore(initial: Partial = {}) { + let config = { providers: {}, ...initial } as PythinkerConfig; + const patches: Array> = []; + const removed: string[] = []; + return { + patches, + removed, + current: () => config, + store: { + ensureConfigFile: async () => undefined, + getConfig: async () => structuredClone(config), + removeProvider: async (providerId: string) => { + removed.push(providerId); + const providers = { ...config.providers }; + delete providers[providerId]; + const models = Object.fromEntries( + Object.entries(config.models ?? {}).filter(([, alias]) => alias.provider !== providerId), + ); + // Mirrors the core: a default pointing at the removed provider is cleared. + config = { + ...config, + providers, + models, + defaultProvider: config.defaultProvider === providerId ? undefined : config.defaultProvider, + defaultModel: + config.defaultModel !== undefined && models[config.defaultModel] === undefined + ? undefined + : config.defaultModel, + } as PythinkerConfig; + return structuredClone(config); + }, + setConfig: async (patch: Partial) => { + patches.push(patch); + config = { ...config, ...patch } as PythinkerConfig; + return structuredClone(config); + }, + }, + }; + } + + const entry = { + id: 'anthropic', + name: 'Anthropic', + api: 'https://api.anthropic.com', + npm: '@ai-sdk/anthropic', + env: ['ANTHROPIC_API_KEY'], + models: { + m1: { id: 'm1', name: 'M1', limit: { context: 200000, output: 64000 } }, + }, + } as unknown as Parameters[1]['entry']; + + it('writes the provider, its aliases, and the requested default model', async () => { + const rig = createStore(); + + const result = await importCatalogProvider(rig.store, { + providerId: 'anthropic', + entry, + apiKey: 'test-key', + defaultModel: 'm1', + }); + + expect(result.defaultModel).toBe('anthropic/m1'); + expect(rig.current().providers['anthropic']).toMatchObject({ apiKey: 'test-key' }); + expect(rig.current().models?.['anthropic/m1']).toBeDefined(); + }); + + it("keeps a default that belongs to another provider when re-importing", async () => { + const rig = createStore({ + providers: { anthropic: { type: 'anthropic' }, other: { type: 'openai' } }, + models: { + 'other/x': { provider: 'other', model: 'x', maxContextSize: 1000 }, + 'anthropic/old': { provider: 'anthropic', model: 'old', maxContextSize: 1000 }, + }, + defaultModel: 'other/x', + defaultProvider: 'other', + defaultThinking: true, + } as Partial); + + await importCatalogProvider(rig.store, { providerId: 'anthropic', entry, apiKey: 'k' }); + + expect(rig.removed).toEqual(['anthropic']); + expect(rig.current().defaultModel).toBe('other/x'); + expect(rig.current().defaultProvider).toBe('other'); + expect(rig.current().defaultThinking).toBe(true); + expect(rig.current().models?.['anthropic/old']).toBeUndefined(); + }); + + it('clears a default the refreshed catalog no longer offers', async () => { + const rig = createStore({ + providers: { anthropic: { type: 'anthropic' } }, + models: { 'anthropic/gone': { provider: 'anthropic', model: 'gone', maxContextSize: 1000 } }, + defaultModel: 'anthropic/gone', + } as Partial); + + await importCatalogProvider(rig.store, { providerId: 'anthropic', entry, apiKey: 'k' }); + + expect(rig.current().defaultModel).toBeUndefined(); + }); + + it('rejects an import with neither a key nor an env var', async () => { + const rig = createStore(); + await expect( + importCatalogProvider(rig.store, { providerId: 'anthropic', entry }), + ).rejects.toThrow(/needs an API key/); + }); + + it('rejects a default model the provider does not offer', async () => { + const rig = createStore(); + await expect( + importCatalogProvider(rig.store, { + providerId: 'anthropic', + entry, + apiKey: 'k', + defaultModel: 'nope', + }), + ).rejects.toThrow(/is not offered/); + }); +}); diff --git a/packages/node-sdk/test/runtime-provider-oauth.test.ts b/packages/node-sdk/test/runtime-provider-oauth.test.ts index 47b28ce7..6ae2af1a 100644 --- a/packages/node-sdk/test/runtime-provider-oauth.test.ts +++ b/packages/node-sdk/test/runtime-provider-oauth.test.ts @@ -7,16 +7,16 @@ import { ProviderManager } from '../../agent-core/src/session/provider-manager'; function managedConfig(): PythinkerConfig { return { providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { 'pythinker-code/pythinker-for-coding': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 262144, }, @@ -118,12 +118,12 @@ describe('resolveRuntimeProviderWithOAuth', () => { const resolved = await resolveRuntimeProviderWithOAuth({ config: managedConfig(), resolveOAuthTokenProvider: (_providerName, oauthRef) => { - expect(oauthRef).toEqual({ storage: 'file', key: 'oauth/pythinker-code' }); + expect(oauthRef).toEqual({ storage: 'file', key: 'oauth/kimi-code' }); return { getAccessToken }; }, }); - expect(resolved.providerName).toBe('managed:pythinker-code'); + expect(resolved.providerName).toBe('managed:kimi-code'); expect(resolved.provider).toMatchObject({ type: 'pythinker', model: 'pythinker-for-coding', @@ -149,11 +149,11 @@ describe('resolveRuntimeProviderWithOAuth', () => { const conflicting: PythinkerConfig = { ...managedConfig(), providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', apiKey: 'static-key', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, }; @@ -196,7 +196,7 @@ describe('resolveRuntimeProviderWithOAuth', () => { expect(log.warn).toHaveBeenCalledWith( 'oauth token fetch failed', expect.objectContaining({ - providerName: 'managed:pythinker-code', + providerName: 'managed:kimi-code', error: expect.any(Error), }), ); diff --git a/packages/node-sdk/test/session-set-model.test.ts b/packages/node-sdk/test/session-set-model.test.ts index 3b6c7d42..631bc611 100644 --- a/packages/node-sdk/test/session-set-model.test.ts +++ b/packages/node-sdk/test/session-set-model.test.ts @@ -66,21 +66,21 @@ describe('Session.setModel', () => { try { await harness.setConfig({ providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { 'pythinker-code/initial': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-initial', maxContextSize: 262144, }, 'pythinker-code/pythinker-for-coding': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 262144, }, diff --git a/packages/oauth/examples/pythinker-oauth-smoke.ts b/packages/oauth/examples/kimi-oauth-smoke.ts similarity index 76% rename from packages/oauth/examples/pythinker-oauth-smoke.ts rename to packages/oauth/examples/kimi-oauth-smoke.ts index c1c3fd80..4b23e016 100644 --- a/packages/oauth/examples/pythinker-oauth-smoke.ts +++ b/packages/oauth/examples/kimi-oauth-smoke.ts @@ -3,28 +3,28 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { - applyManagedPythinkerCodeConfig, - PYTHINKER_CODE_PROVIDER_NAME, + applyManagedKimiCodeConfig, + KIMI_CODE_PROVIDER_NAME, PythinkerOAuthToolkit, type DeviceAuthorization, type PythinkerHostIdentity, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '@pythoughts/pythinker-code-oauth'; async function main(): Promise { const explicitHomeDir = process.env['PYTHINKER_OAUTH_SMOKE_HOME']; - const homeDir = explicitHomeDir ?? (await mkdtemp(join(tmpdir(), 'pythinker-oauth-smoke-'))); + const homeDir = explicitHomeDir ?? (await mkdtemp(join(tmpdir(), 'kimi-oauth-smoke-'))); const keepToken = shouldKeepToken(explicitHomeDir !== undefined); const forceLogin = process.env['PYTHINKER_OAUTH_SMOKE_FORCE_LOGIN'] === '1'; - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; - const toolkit = new PythinkerOAuthToolkit({ + const toolkit = new PythinkerOAuthToolkit({ homeDir, identity: smokeIdentityFromEnv(), configAdapter: { read: () => config, write: () => {}, - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, configPath: '', }, }); @@ -33,16 +33,16 @@ async function main(): Promise { try { if (forceLogin) { - await toolkit.logout(PYTHINKER_CODE_PROVIDER_NAME); + await toolkit.logout(KIMI_CODE_PROVIDER_NAME); process.stdout.write('cleared existing smoke token\n'); } - const login = await toolkit.login(PYTHINKER_CODE_PROVIDER_NAME, { + const login = await toolkit.login(KIMI_CODE_PROVIDER_NAME, { onDeviceCode: printDeviceCode, }); - const status = await toolkit.status(PYTHINKER_CODE_PROVIDER_NAME); - const accessToken = await toolkit.tokenProvider(PYTHINKER_CODE_PROVIDER_NAME).getAccessToken(); - const usage = await toolkit.getManagedUsage(PYTHINKER_CODE_PROVIDER_NAME); + const status = await toolkit.status(KIMI_CODE_PROVIDER_NAME); + const accessToken = await toolkit.tokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(); + const usage = await toolkit.getManagedUsage(KIMI_CODE_PROVIDER_NAME); if (login.provision?.defaultModel === undefined) { throw new Error('login did not provision a default model'); @@ -53,7 +53,7 @@ async function main(): Promise { if (accessToken.length === 0) { throw new Error('token provider returned an empty access token'); } - if (config.providers[PYTHINKER_CODE_PROVIDER_NAME] === undefined) { + if (config.providers[KIMI_CODE_PROVIDER_NAME] === undefined) { throw new Error('managed provider was not written to config'); } @@ -64,7 +64,7 @@ async function main(): Promise { process.stdout.write('oauth smoke passed\n'); } finally { if (!keepToken) { - await toolkit.logout(PYTHINKER_CODE_PROVIDER_NAME).catch(() => {}); + await toolkit.logout(KIMI_CODE_PROVIDER_NAME).catch(() => {}); } if (explicitHomeDir === undefined && !keepToken) { await rm(homeDir, { recursive: true, force: true }); @@ -75,7 +75,7 @@ async function main(): Promise { function smokeIdentityFromEnv(): PythinkerHostIdentity { const version = process.env['PYTHINKER_CODE_SMOKE_VERSION']; if (version === undefined || version.trim().length === 0) { - throw new Error('PYTHINKER_CODE_SMOKE_VERSION is required for Pythinker OAuth smoke.'); + throw new Error('PYTHINKER_CODE_SMOKE_VERSION is required for Kimi OAuth smoke.'); } return { userAgentProduct: "pythinker-code-cli", @@ -86,7 +86,7 @@ function smokeIdentityFromEnv(): PythinkerHostIdentity { function printDeviceCode(auth: DeviceAuthorization): void { process.stdout.write( [ - 'Complete Pythinker OAuth device login:', + 'Complete Kimi OAuth device login:', ` URL: ${auth.verificationUriComplete || auth.verificationUri}`, ` Code: ${auth.userCode}`, auth.expiresIn === null ? undefined : ` Expires in: ${String(auth.expiresIn)}s`, @@ -98,7 +98,7 @@ function printDeviceCode(auth: DeviceAuthorization): void { } function printUsage( - usage: Awaited['getManagedUsage']>>, + usage: Awaited['getManagedUsage']>>, ): void { if (usage.kind === 'error') { process.stderr.write(`usage request returned: ${usage.message}\n`); diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 4038be8c..5357edcf 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -2,7 +2,7 @@ "name": "@pythoughts/pythinker-code-oauth", "version": "0.2.2", "private": true, - "description": "Pythinker OAuth toolkit for managed Pythinker Code authentication", + "description": "OAuth toolkit for managed Kimi Code authentication", "license": "MIT", "author": "Pythoughts", "homepage": "https://github.com/Pythoughts-labs/pythinker-code/tree/main/packages/oauth#readme", diff --git a/packages/oauth/src/constants.ts b/packages/oauth/src/constants.ts index 87cf6bd7..7e86c471 100644 --- a/packages/oauth/src/constants.ts +++ b/packages/oauth/src/constants.ts @@ -1,12 +1,12 @@ import type { OAuthFlowConfig } from './types'; -export const DEFAULT_PYTHINKER_CODE_OAUTH_HOST = 'https://auth.kimi.com'; +export const DEFAULT_KIMI_CODE_OAUTH_HOST = 'https://auth.kimi.com'; -export const PYTHINKER_CODE_FLOW_CONFIG: OAuthFlowConfig = { - name: 'pythinker-code', +export const KIMI_CODE_FLOW_CONFIG: OAuthFlowConfig = { + name: 'kimi-code', oauthHost: process.env['PYTHINKER_CODE_OAUTH_HOST'] ?? process.env['PYTHINKER_OAUTH_HOST'] ?? - DEFAULT_PYTHINKER_CODE_OAUTH_HOST, + DEFAULT_KIMI_CODE_OAUTH_HOST, clientId: '17e5f671-d194-4dfb-9706-5516cb48c098', }; diff --git a/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts index 29b4e142..6113e16a 100644 --- a/packages/oauth/src/custom-registry.ts +++ b/packages/oauth/src/custom-registry.ts @@ -1,8 +1,8 @@ import { readApiErrorMessage } from './api-error'; import { isRecord } from './utils'; -import type { ManagedPythinkerConfigShape } from './managed-pythinker-code'; +import type { ManagedKimiConfigShape } from './managed-kimi-code'; -export type { ManagedPythinkerConfigShape }; +export type { ManagedKimiConfigShape }; /** * Identifies where a custom-registry-managed provider came from. The same @@ -288,11 +288,11 @@ function resolveCapabilities(model: CustomRegistryModelEntry): string[] { * Mirrors `applyOpenPlatformConfig`'s shape: provider goes to `config.providers` * keyed by `entry.id`, each model in `entry.models` becomes an alias under * `config.models[\`${entry.id}/${modelId}\`]`. The `source` blob is parked on the - * provider object via `ManagedPythinkerProviderConfig`'s index signature so the + * provider object via `ManagedKimiProviderConfig`'s index signature so the * refresh dispatcher can rediscover it later. */ export function applyCustomRegistryProvider( - config: ManagedPythinkerConfigShape, + config: ManagedKimiConfigShape, entry: CustomRegistryProviderEntry, source: CustomRegistrySource, ): void { @@ -339,7 +339,7 @@ export function applyCustomRegistryProvider( * `removeOpenPlatformConfig`. */ export function removeCustomRegistryProvider( - config: ManagedPythinkerConfigShape, + config: ManagedKimiConfigShape, providerId: string, ): void { delete config.providers[providerId]; @@ -383,7 +383,7 @@ export function removeCustomRegistryProvider( * registry". */ export function applyCustomRegistryEntries( - config: ManagedPythinkerConfigShape, + config: ManagedKimiConfigShape, entries: Record, source: CustomRegistrySource, ): void { diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index eae88ffb..f75c7b2b 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -43,48 +43,48 @@ export { } from './identity'; export type { PythinkerHostIdentity, PythinkerIdentityOptions } from './identity'; -export { PYTHINKER_CODE_FLOW_CONFIG } from './constants'; +export { KIMI_CODE_FLOW_CONFIG } from './constants'; export { - applyManagedPythinkerCodeLogoutConfig, - applyManagedPythinkerCodeConfig, - clearManagedPythinkerCodeConfig, - fetchManagedPythinkerCodeModels, - pythinkerCodeEnvBaseUrl, - pythinkerCodeEnvOAuthHost, - PYTHINKER_CODE_OAUTH_KEY, - PYTHINKER_CODE_PLATFORM_ID, - PYTHINKER_CODE_PROVIDER_NAME, - ManagedPythinkerCodeModelsAuthError, - provisionManagedPythinkerCodeConfig, - resolvePythinkerCodeLoginAuth, - resolvePythinkerCodeOAuthKey, - resolvePythinkerCodeOAuthRef, - resolvePythinkerCodeRuntimeAuth, -} from './managed-pythinker-code'; + applyManagedKimiCodeLogoutConfig, + applyManagedKimiCodeConfig, + clearManagedKimiCodeConfig, + fetchManagedKimiCodeModels, + kimiCodeEnvBaseUrl, + kimiCodeEnvOAuthHost, + KIMI_CODE_OAUTH_KEY, + KIMI_CODE_PLATFORM_ID, + KIMI_CODE_PROVIDER_NAME, + ManagedKimiCodeModelsAuthError, + provisionManagedKimiCodeConfig, + resolveKimiCodeLoginAuth, + resolveKimiCodeOAuthKey, + resolveKimiCodeOAuthRef, + resolveKimiCodeRuntimeAuth, +} from './managed-kimi-code'; export type { - FetchManagedPythinkerCodeModelsOptions, - ManagedPythinkerCodeApplyResult, - ManagedPythinkerCodeCleanupResult, - ManagedPythinkerEnv, - ManagedPythinkerLoginAuth, - ManagedPythinkerCodeModelInfo, - ManagedPythinkerCodeProvisionResult, - ManagedPythinkerConfigAdapter, - ManagedPythinkerConfigShape, - ManagedPythinkerOAuthRef, - ManagedPythinkerOAuthRefInput, - ManagedPythinkerRuntimeAuth, - ProvisionManagedPythinkerCodeConfigOptions, -} from './managed-pythinker-code'; + FetchManagedKimiCodeModelsOptions, + ManagedKimiCodeApplyResult, + ManagedKimiCodeCleanupResult, + ManagedKimiEnv, + ManagedKimiLoginAuth, + ManagedKimiCodeModelInfo, + ManagedKimiCodeProvisionResult, + ManagedKimiConfigAdapter, + ManagedKimiConfigShape, + ManagedKimiOAuthRef, + ManagedKimiOAuthRefInput, + ManagedKimiRuntimeAuth, + ProvisionManagedKimiCodeConfigOptions, +} from './managed-kimi-code'; export { fetchManagedUsage, formatDuration, formatResetTime, - isManagedPythinkerCode, - pythinkerCodeBaseUrl, - pythinkerCodeUsageUrl, + isManagedKimiCode, + kimiCodeBaseUrl, + kimiCodeUsageUrl, parseManagedUsagePayload, } from './managed-usage'; export type { @@ -94,7 +94,7 @@ export type { UsageRow, } from './managed-usage'; -export { fetchSubmitFeedback, pythinkerCodeFeedbackUrl } from './managed-feedback'; +export { fetchSubmitFeedback, kimiCodeFeedbackUrl } from './managed-feedback'; export type { FetchSubmitFeedbackError, FetchSubmitFeedbackOk, diff --git a/packages/oauth/src/managed-feedback.ts b/packages/oauth/src/managed-feedback.ts index d3ae1e85..faa65eeb 100644 --- a/packages/oauth/src/managed-feedback.ts +++ b/packages/oauth/src/managed-feedback.ts @@ -1,13 +1,13 @@ /** * Submit user feedback to the managed Pythinker Code platform. * - * POSTs a JSON body to `{pythinkerCodeBaseUrl}/feedback` with a Bearer access + * POSTs a JSON body to `{kimiCodeBaseUrl}/feedback` with a Bearer access * token. The client tags `version` with a `pythinker-code-` prefix so the * backend can identify this client. */ import { readApiErrorMessage } from './api-error'; -import { pythinkerCodeBaseUrl } from './managed-usage'; +import { kimiCodeBaseUrl } from './managed-usage'; export interface SubmitFeedbackBody { readonly session_id: string; @@ -29,8 +29,8 @@ export interface FetchSubmitFeedbackError { export type FetchSubmitFeedbackResult = FetchSubmitFeedbackOk | FetchSubmitFeedbackError; -export function pythinkerCodeFeedbackUrl(): string { - return `${pythinkerCodeBaseUrl().replace(/\/+$/, '')}/feedback`; +export function kimiCodeFeedbackUrl(): string { + return `${kimiCodeBaseUrl().replace(/\/+$/, '')}/feedback`; } export async function fetchSubmitFeedback( diff --git a/packages/oauth/src/managed-pythinker-code.ts b/packages/oauth/src/managed-kimi-code.ts similarity index 69% rename from packages/oauth/src/managed-pythinker-code.ts rename to packages/oauth/src/managed-kimi-code.ts index 9254d59d..8050c88b 100644 --- a/packages/oauth/src/managed-pythinker-code.ts +++ b/packages/oauth/src/managed-kimi-code.ts @@ -1,15 +1,15 @@ import { createHash } from 'node:crypto'; import { readApiErrorMessage } from './api-error'; -import { DEFAULT_PYTHINKER_CODE_OAUTH_HOST } from './constants'; +import { DEFAULT_KIMI_CODE_OAUTH_HOST } from './constants'; import { OAuthUnauthorizedError } from './errors'; -import { DEFAULT_PYTHINKER_CODE_BASE_URL, pythinkerCodeBaseUrl } from './managed-usage'; +import { DEFAULT_KIMI_CODE_BASE_URL, kimiCodeBaseUrl } from './managed-usage'; import { isRecord } from './utils'; -export const PYTHINKER_CODE_PLATFORM_ID = 'pythinker-code'; -export const PYTHINKER_CODE_PROVIDER_NAME = 'managed:pythinker-code'; -export const PYTHINKER_CODE_OAUTH_KEY = 'oauth/pythinker-code'; -const PYTHINKER_CODE_SCOPED_OAUTH_KEY_PREFIX = 'oauth/pythinker-code-env-'; +export const KIMI_CODE_PLATFORM_ID = 'kimi-code'; +export const KIMI_CODE_PROVIDER_NAME = 'managed:kimi-code'; +export const KIMI_CODE_OAUTH_KEY = 'oauth/kimi-code'; +const KIMI_CODE_SCOPED_OAUTH_KEY_PREFIX = 'oauth/kimi-code-env-'; /** * Server-declared thinking toggle support from `/models`: @@ -21,11 +21,11 @@ const PYTHINKER_CODE_SCOPED_OAUTH_KEY_PREFIX = 'oauth/pythinker-code-env-'; export type SupportsThinkingType = 'only' | 'no' | 'both'; /** - * Normalized model catalog entry returned by the managed Pythinker Code + * Normalized model catalog entry returned by the managed Kimi Code * `/models` endpoint; raw snake_case server fields map to these camelCase * fields. */ -export interface ManagedPythinkerCodeModelInfo { +export interface ManagedKimiCodeModelInfo { readonly id: string; readonly contextLength: number; readonly supportsReasoning: boolean; @@ -38,63 +38,63 @@ export interface ManagedPythinkerCodeModelInfo { readonly displayName?: string | undefined; } -export interface ManagedPythinkerCodeProvisionResult { - readonly providerName: typeof PYTHINKER_CODE_PROVIDER_NAME; +export interface ManagedKimiCodeProvisionResult { + readonly providerName: typeof KIMI_CODE_PROVIDER_NAME; readonly defaultModel: string; readonly defaultThinking: boolean; - readonly models: readonly ManagedPythinkerCodeModelInfo[]; + readonly models: readonly ManagedKimiCodeModelInfo[]; readonly configPath?: string | undefined; } -export interface FetchManagedPythinkerCodeModelsOptions { +export interface FetchManagedKimiCodeModelsOptions { readonly accessToken: string; readonly baseUrl?: string | undefined; readonly fetchImpl?: typeof fetch | undefined; } -export interface ManagedPythinkerCodeApplyResult { +export interface ManagedKimiCodeApplyResult { readonly defaultModel: string; readonly defaultThinking: boolean; } -export interface ManagedPythinkerCodeCleanupResult { - readonly providerName: typeof PYTHINKER_CODE_PROVIDER_NAME; +export interface ManagedKimiCodeCleanupResult { + readonly providerName: typeof KIMI_CODE_PROVIDER_NAME; readonly removedProvider: boolean; readonly removedModels: readonly string[]; readonly defaultModelCleared: boolean; readonly removedServices: readonly string[]; } -export interface ManagedPythinkerOAuthRef { +export interface ManagedKimiOAuthRef { readonly storage: 'file' | 'keyring'; readonly key: string; readonly oauthHost?: string | undefined; } -export interface ManagedPythinkerOAuthRefInput { +export interface ManagedKimiOAuthRefInput { readonly storage?: 'file' | 'keyring' | undefined; readonly key?: string | undefined; readonly oauthHost?: string | undefined; } -export interface ManagedPythinkerRuntimeAuth { +export interface ManagedKimiRuntimeAuth { readonly baseUrl?: string | undefined; - readonly oauthRef: ManagedPythinkerOAuthRef; + readonly oauthRef: ManagedKimiOAuthRef; } -export interface ManagedPythinkerLoginAuth { +export interface ManagedKimiLoginAuth { readonly baseUrl?: string | undefined; readonly oauthHost?: string | undefined; - readonly oauthRef?: ManagedPythinkerOAuthRef | undefined; + readonly oauthRef?: ManagedKimiOAuthRef | undefined; } -export interface ManagedPythinkerEnv { +export interface ManagedKimiEnv { readonly PYTHINKER_CODE_BASE_URL?: string | undefined; readonly PYTHINKER_CODE_OAUTH_HOST?: string | undefined; readonly PYTHINKER_OAUTH_HOST?: string | undefined; } -export class ManagedPythinkerCodeModelsAuthError extends OAuthUnauthorizedError { +export class ManagedKimiCodeModelsAuthError extends OAuthUnauthorizedError { readonly status: number; readonly baseUrl: string; @@ -104,23 +104,23 @@ export class ManagedPythinkerCodeModelsAuthError extends OAuthUnauthorizedError readonly message: string; }) { super( - `Pythinker Code models endpoint ${options.baseUrl} rejected OAuth credentials: ${options.message}`, + `Kimi Code models endpoint ${options.baseUrl} rejected OAuth credentials: ${options.message}`, ); - this.name = 'ManagedPythinkerCodeModelsAuthError'; + this.name = 'ManagedKimiCodeModelsAuthError'; this.status = options.status; this.baseUrl = options.baseUrl; } } -export interface ManagedPythinkerProviderConfig { +export interface ManagedKimiProviderConfig { type: 'pythinker'; baseUrl?: string | undefined; apiKey?: string | undefined; - oauth?: ManagedPythinkerOAuthRef | undefined; + oauth?: ManagedKimiOAuthRef | undefined; readonly [key: string]: unknown; } -export interface ManagedPythinkerModelAlias { +export interface ManagedKimiModelAlias { provider: string; model: string; maxContextSize: number; @@ -130,50 +130,50 @@ export interface ManagedPythinkerModelAlias { readonly [key: string]: unknown; } -export interface ManagedPythinkerServiceConfig { +export interface ManagedKimiServiceConfig { baseUrl?: string | undefined; apiKey?: string | undefined; - oauth?: ManagedPythinkerOAuthRef | undefined; + oauth?: ManagedKimiOAuthRef | undefined; } -export interface ManagedPythinkerServicesConfig { - pythoughtsSearch?: ManagedPythinkerServiceConfig | undefined; - pythoughtsFetch?: ManagedPythinkerServiceConfig | undefined; +export interface ManagedKimiServicesConfig { + pythoughtsSearch?: ManagedKimiServiceConfig | undefined; + pythoughtsFetch?: ManagedKimiServiceConfig | undefined; readonly [key: string]: unknown; } -export interface ManagedPythinkerConfigShape { - providers: Record>; - models?: Record> | undefined; +export interface ManagedKimiConfigShape { + providers: Record>; + models?: Record> | undefined; defaultModel?: string | undefined; defaultThinking?: boolean | undefined; thinking?: { mode?: 'auto' | 'on' | 'off'; effort?: string; }; - services?: ManagedPythinkerServicesConfig | undefined; + services?: ManagedKimiServicesConfig | undefined; [key: string]: unknown; } -export interface ManagedPythinkerConfigAdapter { +export interface ManagedKimiConfigAdapter { read(): Promise | TConfig; write(config: TConfig): Promise | void; apply( config: TConfig, input: { - readonly models: readonly ManagedPythinkerCodeModelInfo[]; + readonly models: readonly ManagedKimiCodeModelInfo[]; readonly baseUrl?: string | undefined; readonly oauthKey?: string | undefined; readonly oauthHost?: string | undefined; readonly preserveDefaultModel?: boolean | undefined; }, - ): ManagedPythinkerCodeApplyResult; + ): ManagedKimiCodeApplyResult; remove?(config: TConfig): void; readonly configPath?: string | undefined; } -export interface ProvisionManagedPythinkerCodeConfigOptions { - readonly adapter: ManagedPythinkerConfigAdapter; +export interface ProvisionManagedKimiCodeConfigOptions { + readonly adapter: ManagedKimiConfigAdapter; readonly accessToken: string; readonly baseUrl?: string | undefined; readonly oauthKey?: string | undefined; @@ -183,7 +183,7 @@ export interface ProvisionManagedPythinkerCodeConfigOptions { } function managedModelKey(modelId: string): string { - return `${PYTHINKER_CODE_PLATFORM_ID}/${modelId}`; + return `${KIMI_CODE_PLATFORM_ID}/${modelId}`; } interface SelectedDefaultModel { @@ -191,7 +191,7 @@ interface SelectedDefaultModel { readonly thinking: boolean; } -function capabilitiesForModel(model: ManagedPythinkerCodeModelInfo): string[] | undefined { +function capabilitiesForModel(model: ManagedKimiCodeModelInfo): string[] | undefined { const caps = new Set(); // supports_thinking_type is the full three-state declaration and wins over // the legacy supports_reasoning boolean; absent (older servers) falls back. @@ -219,7 +219,7 @@ function capabilitiesForModel(model: ManagedPythinkerCodeModelInfo): string[] | } function defaultBaseUrl(baseUrl: string | undefined): string { - return (baseUrl ?? pythinkerCodeBaseUrl()).replace(/\/+$/, ''); + return (baseUrl ?? kimiCodeBaseUrl()).replace(/\/+$/, ''); } function normalizeBaseUrl(baseUrl: string): string { @@ -235,10 +235,10 @@ function persistedOAuthHost(options: { readonly oauthHost?: string | undefined; }): string | undefined { const oauthHost = options.oauthHost; - const normalized = normalizeEndpoint(oauthHost ?? DEFAULT_PYTHINKER_CODE_OAUTH_HOST); + const normalized = normalizeEndpoint(oauthHost ?? DEFAULT_KIMI_CODE_OAUTH_HOST); if ( - options.key === PYTHINKER_CODE_OAUTH_KEY && - normalized === normalizeEndpoint(DEFAULT_PYTHINKER_CODE_OAUTH_HOST) + options.key === KIMI_CODE_OAUTH_KEY && + normalized === normalizeEndpoint(DEFAULT_KIMI_CODE_OAUTH_HOST) ) { return undefined; } @@ -249,7 +249,7 @@ function managedOAuthRef(options: { readonly key: string; readonly oauthHost?: string | undefined; readonly storage?: 'file' | 'keyring' | undefined; -}): ManagedPythinkerOAuthRef { +}): ManagedKimiOAuthRef { const oauthHost = persistedOAuthHost(options); return { storage: options.storage ?? 'file', @@ -259,8 +259,8 @@ function managedOAuthRef(options: { } function configuredOAuthRef( - oauthRef: ManagedPythinkerOAuthRefInput | undefined, -): ManagedPythinkerOAuthRef | undefined { + oauthRef: ManagedKimiOAuthRefInput | undefined, +): ManagedKimiOAuthRef | undefined { if (oauthRef === undefined) return undefined; const key = oauthRef.key; if (key === undefined) return undefined; @@ -272,16 +272,16 @@ function configuredOAuthRef( } /** - * Managed Pythinker Code base URL from the environment. + * Managed Kimi Code base URL from the environment. */ -export function pythinkerCodeEnvBaseUrl(env: ManagedPythinkerEnv = process.env): string | undefined { +export function kimiCodeEnvBaseUrl(env: ManagedKimiEnv = process.env): string | undefined { return env.PYTHINKER_CODE_BASE_URL; } /** - * Managed Pythinker Code OAuth host from the environment. + * Managed Kimi Code OAuth host from the environment. */ -export function pythinkerCodeEnvOAuthHost(env: ManagedPythinkerEnv = process.env): string | undefined { +export function kimiCodeEnvOAuthHost(env: ManagedKimiEnv = process.env): string | undefined { return env.PYTHINKER_CODE_OAUTH_HOST ?? env.PYTHINKER_OAUTH_HOST; } @@ -289,28 +289,28 @@ export function pythinkerCodeEnvOAuthHost(env: ManagedPythinkerEnv = process.env * Returns the credential-storage key for an (oauthHost, baseUrl) pair: the * global slot for defaults, otherwise a hash-scoped per-environment slot. */ -export function resolvePythinkerCodeOAuthKey(options: { +export function resolveKimiCodeOAuthKey(options: { readonly oauthHost?: string | undefined; readonly baseUrl?: string | undefined; }): string { - const oauthHost = normalizeEndpoint(options.oauthHost ?? DEFAULT_PYTHINKER_CODE_OAUTH_HOST); + const oauthHost = normalizeEndpoint(options.oauthHost ?? DEFAULT_KIMI_CODE_OAUTH_HOST); const baseUrl = defaultBaseUrl(options.baseUrl); - const defaultOauthHost = normalizeEndpoint(DEFAULT_PYTHINKER_CODE_OAUTH_HOST); - const defaultApiBaseUrl = normalizeEndpoint(DEFAULT_PYTHINKER_CODE_BASE_URL); + const defaultOauthHost = normalizeEndpoint(DEFAULT_KIMI_CODE_OAUTH_HOST); + const defaultApiBaseUrl = normalizeEndpoint(DEFAULT_KIMI_CODE_BASE_URL); if (oauthHost === defaultOauthHost && baseUrl === defaultApiBaseUrl) { - return PYTHINKER_CODE_OAUTH_KEY; + return KIMI_CODE_OAUTH_KEY; } const digest = createHash('sha256') .update(JSON.stringify({ oauthHost, baseUrl })) .digest('hex') .slice(0, 16); - return `${PYTHINKER_CODE_SCOPED_OAUTH_KEY_PREFIX}${digest}`; + return `${KIMI_CODE_SCOPED_OAUTH_KEY_PREFIX}${digest}`; } /** - * Resolve the full managed-Pythinker-Code OAuth ref (credential storage key + + * Resolve the full managed-Kimi-Code OAuth ref (credential storage key + * persisted host) for an (oauthHost, baseUrl) environment. * * Single source of truth for "which credential slot does this environment map @@ -319,12 +319,12 @@ export function resolvePythinkerCodeOAuthKey(options: { * is later read from — preventing the env-mismatch credential mix-ups this * scoping is meant to fix. */ -export function resolvePythinkerCodeOAuthRef(options: { +export function resolveKimiCodeOAuthRef(options: { readonly oauthHost?: string | undefined; readonly baseUrl?: string | undefined; -}): ManagedPythinkerOAuthRef { +}): ManagedKimiOAuthRef { return managedOAuthRef({ - key: resolvePythinkerCodeOAuthKey(options), + key: resolveKimiCodeOAuthKey(options), oauthHost: options.oauthHost, }); } @@ -334,18 +334,18 @@ export function resolvePythinkerCodeOAuthRef(options: { * the runtime auth the managed provider should use, migrating to the env's * credential slot when they disagree. */ -export function resolvePythinkerCodeRuntimeAuth(options: { +export function resolveKimiCodeRuntimeAuth(options: { readonly configuredBaseUrl?: string | undefined; - readonly configuredOAuthRef?: ManagedPythinkerOAuthRefInput | undefined; - readonly env?: ManagedPythinkerEnv | undefined; -}): ManagedPythinkerRuntimeAuth { + readonly configuredOAuthRef?: ManagedKimiOAuthRefInput | undefined; + readonly env?: ManagedKimiEnv | undefined; +}): ManagedKimiRuntimeAuth { const env = options.env ?? process.env; - const envBaseUrl = pythinkerCodeEnvBaseUrl(env); - const envOAuthHost = pythinkerCodeEnvOAuthHost(env); + const envBaseUrl = kimiCodeEnvBaseUrl(env); + const envOAuthHost = kimiCodeEnvOAuthHost(env); const hasEnvOverride = envBaseUrl !== undefined || envOAuthHost !== undefined; const baseUrl = envBaseUrl !== undefined ? normalizeBaseUrl(envBaseUrl) : options.configuredBaseUrl; - const expected = resolvePythinkerCodeOAuthRef({ + const expected = resolveKimiCodeOAuthRef({ oauthHost: hasEnvOverride ? envOAuthHost : options.configuredOAuthRef?.oauthHost, baseUrl, }); @@ -361,16 +361,16 @@ export function resolvePythinkerCodeRuntimeAuth(options: { * then env overrides, then the configured ref validated against the key the * resolved base URL implies. */ -export function resolvePythinkerCodeLoginAuth(options: { +export function resolveKimiCodeLoginAuth(options: { readonly configuredBaseUrl?: string | undefined; - readonly configuredOAuthRef?: ManagedPythinkerOAuthRefInput | undefined; + readonly configuredOAuthRef?: ManagedKimiOAuthRefInput | undefined; readonly requestedBaseUrl?: string | undefined; readonly requestedOAuthHost?: string | undefined; - readonly env?: ManagedPythinkerEnv | undefined; -}): ManagedPythinkerLoginAuth { + readonly env?: ManagedKimiEnv | undefined; +}): ManagedKimiLoginAuth { const env = options.env ?? process.env; - const envBaseUrl = pythinkerCodeEnvBaseUrl(env); - const envOAuthHost = pythinkerCodeEnvOAuthHost(env); + const envBaseUrl = kimiCodeEnvBaseUrl(env); + const envOAuthHost = kimiCodeEnvOAuthHost(env); const hasOverride = options.requestedBaseUrl !== undefined || options.requestedOAuthHost !== undefined || @@ -387,7 +387,7 @@ export function resolvePythinkerCodeLoginAuth(options: { const configured = configuredOAuthRef(options.configuredOAuthRef); if (configured === undefined) return { baseUrl, oauthHost }; - const expectedKey = resolvePythinkerCodeOAuthKey({ + const expectedKey = resolveKimiCodeOAuthKey({ oauthHost: configured.oauthHost, baseUrl, }); @@ -396,13 +396,13 @@ export function resolvePythinkerCodeLoginAuth(options: { : { baseUrl, oauthHost }; } -function toModelInfo(item: unknown): ManagedPythinkerCodeModelInfo | undefined { +function toModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { if (!isRecord(item) || typeof item['id'] !== 'string' || item['id'].length === 0) { return undefined; } const contextLength = Number(item['context_length']); if (!Number.isInteger(contextLength) || contextLength <= 0) { - throw new Error(`Pythinker Code model "${item['id']}" must include a positive context_length.`); + throw new Error(`Kimi Code model "${item['id']}" must include a positive context_length.`); } const displayName = item['display_name']; const normalizedDisplayName = @@ -439,13 +439,13 @@ export function parseSupportsThinkingType(value: unknown): SupportsThinkingType } /** - * Lists the managed Pythinker Code models for an access token. Throws - * ManagedPythinkerCodeModelsAuthError on 401/402/403 so callers can trigger + * Lists the managed Kimi Code models for an access token. Throws + * ManagedKimiCodeModelsAuthError on 401/402/403 so callers can trigger * re-login. */ -export async function fetchManagedPythinkerCodeModels( - options: FetchManagedPythinkerCodeModelsOptions, -): Promise { +export async function fetchManagedKimiCodeModels( + options: FetchManagedKimiCodeModelsOptions, +): Promise { const fetchImpl = options.fetchImpl ?? fetch; const baseUrl = defaultBaseUrl(options.baseUrl); const response = await fetchImpl(`${baseUrl}/models`, { @@ -457,10 +457,10 @@ export async function fetchManagedPythinkerCodeModels( if (!response.ok) { const message = await readApiErrorMessage( response, - `Failed to list Pythinker Code models (HTTP ${response.status}).`, + `Failed to list Kimi Code models (HTTP ${response.status}).`, ); if (response.status === 401 || response.status === 402 || response.status === 403) { - throw new ManagedPythinkerCodeModelsAuthError({ + throw new ManagedKimiCodeModelsAuthError({ status: response.status, baseUrl, message, @@ -474,7 +474,7 @@ export async function fetchManagedPythinkerCodeModels( } return payload['data'] .map((item) => toModelInfo(item)) - .filter((item): item is ManagedPythinkerCodeModelInfo => item !== undefined); + .filter((item): item is ManagedKimiCodeModelInfo => item !== undefined); } /** @@ -482,18 +482,18 @@ export async function fetchManagedPythinkerCodeModels( * model into the config in place. The current default is preserved when * preserveDefaultModel is set and it still exists in the new catalog. */ -export function applyManagedPythinkerCodeConfig( - config: ManagedPythinkerConfigShape, +export function applyManagedKimiCodeConfig( + config: ManagedKimiConfigShape, options: { - readonly models: readonly ManagedPythinkerCodeModelInfo[]; + readonly models: readonly ManagedKimiCodeModelInfo[]; readonly baseUrl?: string | undefined; readonly oauthKey?: string | undefined; readonly oauthHost?: string | undefined; readonly preserveDefaultModel?: boolean | undefined; }, -): ManagedPythinkerCodeApplyResult { +): ManagedKimiCodeApplyResult { if (options.models.length === 0) { - throw new Error('No models available for Pythinker Code.'); + throw new Error('No models available for Kimi Code.'); } for (const model of options.models) { assertPositiveContextLength(model); @@ -503,13 +503,13 @@ export function applyManagedPythinkerCodeConfig( const oauth = options.oauthKey !== undefined ? managedOAuthRef({ key: options.oauthKey, oauthHost: options.oauthHost }) - : resolvePythinkerCodeOAuthRef({ baseUrl, oauthHost: options.oauthHost }); + : resolveKimiCodeOAuthRef({ baseUrl, oauthHost: options.oauthHost }); const existingModels = config.models ?? {}; const selectedDefault = selectDefaultModel(config, options.models, { preserveExisting: options.preserveDefaultModel === true, }); - config.providers[PYTHINKER_CODE_PROVIDER_NAME] = { + config.providers[KIMI_CODE_PROVIDER_NAME] = { type: 'pythinker', baseUrl, apiKey: '', @@ -517,14 +517,14 @@ export function applyManagedPythinkerCodeConfig( }; for (const [key, model] of Object.entries(existingModels)) { - if (isRecord(model) && model['provider'] === PYTHINKER_CODE_PROVIDER_NAME) { + if (isRecord(model) && model['provider'] === KIMI_CODE_PROVIDER_NAME) { delete existingModels[key]; } } for (const model of options.models) { const capabilities = capabilitiesForModel(model); existingModels[managedModelKey(model.id)] = { - provider: PYTHINKER_CODE_PROVIDER_NAME, + provider: KIMI_CODE_PROVIDER_NAME, model: model.id, maxContextSize: model.contextLength, capabilities, @@ -562,13 +562,13 @@ export function applyManagedPythinkerCodeConfig( * Removes the managed provider and its aliases/services from the config, * clearing defaults that pointed at them. */ -export function applyManagedPythinkerCodeLogoutConfig(config: ManagedPythinkerConfigShape): void { - delete config.providers[PYTHINKER_CODE_PROVIDER_NAME]; +export function applyManagedKimiCodeLogoutConfig(config: ManagedKimiConfigShape): void { + delete config.providers[KIMI_CODE_PROVIDER_NAME]; let removedDefaultModel = false; const existingModels = config.models ?? {}; for (const [key, model] of Object.entries(existingModels)) { - if (!isRecord(model) || model['provider'] !== PYTHINKER_CODE_PROVIDER_NAME) continue; + if (!isRecord(model) || model['provider'] !== KIMI_CODE_PROVIDER_NAME) continue; delete existingModels[key]; if (config.defaultModel === key) removedDefaultModel = true; } @@ -578,7 +578,7 @@ export function applyManagedPythinkerCodeLogoutConfig(config: ManagedPythinkerCo config.defaultModel = undefined; } - if (config['defaultProvider'] === PYTHINKER_CODE_PROVIDER_NAME) { + if (config['defaultProvider'] === KIMI_CODE_PROVIDER_NAME) { config['defaultProvider'] = undefined; } @@ -596,7 +596,7 @@ export function applyManagedPythinkerCodeLogoutConfig(config: ManagedPythinkerCo // must never end up with thinking off, and a non-thinking model ('no') must // never end up with thinking on. function forcedThinking( - model: ManagedPythinkerCodeModelInfo | undefined, + model: ManagedKimiCodeModelInfo | undefined, fallback: boolean, ): boolean { if (model?.supportsThinkingType === 'only') return true; @@ -605,13 +605,13 @@ function forcedThinking( } function selectDefaultModel( - config: ManagedPythinkerConfigShape, - models: readonly ManagedPythinkerCodeModelInfo[], + config: ManagedKimiConfigShape, + models: readonly ManagedKimiCodeModelInfo[], options: { readonly preserveExisting: boolean }, ): SelectedDefaultModel { const firstModel = models[0]; if (firstModel === undefined) { - throw new Error('No models available for Pythinker Code.'); + throw new Error('No models available for Kimi Code.'); } const managedModels = new Map(models.map((model) => [managedModelKey(model.id), model])); @@ -643,30 +643,30 @@ function selectDefaultModel( } function canPreserveDefaultModel( - existingModels: Record>, + existingModels: Record>, defaultModel: string, - managedModels: ReadonlyMap, + managedModels: ReadonlyMap, ): boolean { if (managedModels.has(defaultModel)) return true; const existing = existingModels[defaultModel]; - return isRecord(existing) && existing['provider'] !== PYTHINKER_CODE_PROVIDER_NAME; + return isRecord(existing) && existing['provider'] !== KIMI_CODE_PROVIDER_NAME; } /** * Removes the managed provider, model aliases, and services from the config * and reports what was actually removed. */ -export function clearManagedPythinkerCodeConfig( - config: ManagedPythinkerConfigShape, -): ManagedPythinkerCodeCleanupResult { - const removedProvider = Object.hasOwn(config.providers, PYTHINKER_CODE_PROVIDER_NAME); - delete config.providers[PYTHINKER_CODE_PROVIDER_NAME]; +export function clearManagedKimiCodeConfig( + config: ManagedKimiConfigShape, +): ManagedKimiCodeCleanupResult { + const removedProvider = Object.hasOwn(config.providers, KIMI_CODE_PROVIDER_NAME); + delete config.providers[KIMI_CODE_PROVIDER_NAME]; const removedModels: string[] = []; const models = config.models; if (models !== undefined) { for (const [key, model] of Object.entries(models)) { - if (!isRecord(model) || model['provider'] !== PYTHINKER_CODE_PROVIDER_NAME) continue; + if (!isRecord(model) || model['provider'] !== KIMI_CODE_PROVIDER_NAME) continue; delete models[key]; removedModels.push(key); } @@ -692,7 +692,7 @@ export function clearManagedPythinkerCodeConfig( } return { - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, removedProvider, removedModels, defaultModelCleared, @@ -700,26 +700,26 @@ export function clearManagedPythinkerCodeConfig( }; } -function assertPositiveContextLength(model: ManagedPythinkerCodeModelInfo): void { +function assertPositiveContextLength(model: ManagedKimiCodeModelInfo): void { if (!Number.isInteger(model.contextLength) || model.contextLength <= 0) { - throw new Error(`Pythinker Code model "${model.id}" must include a positive context_length.`); + throw new Error(`Kimi Code model "${model.id}" must include a positive context_length.`); } } -export async function provisionManagedPythinkerCodeConfigAfterLogin( - options: ProvisionManagedPythinkerCodeConfigOptions, -): Promise { - return provisionManagedPythinkerCodeConfig(options); +export async function provisionManagedKimiCodeConfigAfterLogin( + options: ProvisionManagedKimiCodeConfigOptions, +): Promise { + return provisionManagedKimiCodeConfig(options); } /** * Full login-completion flow: fetches models, applies the provider config via * the adapter, and persists it. Returns the provisioned provider summary. */ -export async function provisionManagedPythinkerCodeConfig( - options: ProvisionManagedPythinkerCodeConfigOptions, -): Promise { - const models = await fetchManagedPythinkerCodeModels(options); +export async function provisionManagedKimiCodeConfig( + options: ProvisionManagedKimiCodeConfigOptions, +): Promise { + const models = await fetchManagedKimiCodeModels(options); const config = await options.adapter.read(); const applied = options.adapter.apply(config, { models, @@ -730,7 +730,7 @@ export async function provisionManagedPythinkerCodeConfig( }); await options.adapter.write(config); return { - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, defaultModel: applied.defaultModel, defaultThinking: applied.defaultThinking, models, diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index ac998ac2..04c4e818 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -1,7 +1,7 @@ /** * Managed-platform usage fetch / parse. * - * Only `managed:pythinker-code` is supported today. The platform exposes a + * Only `managed:kimi-code` is supported today. The platform exposes a * `/usages` endpoint that returns a payload of the shape: * * { @@ -21,21 +21,21 @@ import { readApiErrorMessage } from './api-error'; import { isRecord } from './utils'; const MANAGED_PREFIX = 'managed:'; -const PYTHINKER_CODE_PLATFORM_ID = 'pythinker-code'; -export const DEFAULT_PYTHINKER_CODE_BASE_URL = 'https://api.kimi.com/coding/v1'; +const KIMI_CODE_PLATFORM_ID = 'kimi-code'; +export const DEFAULT_KIMI_CODE_BASE_URL = 'https://api.kimi.com/coding/v1'; -export function isManagedPythinkerCode(providerKey?: string | null): boolean { +export function isManagedKimiCode(providerKey?: string | null): boolean { if (!providerKey) return false; if (!providerKey.startsWith(MANAGED_PREFIX)) return false; - return providerKey.slice(MANAGED_PREFIX.length) === PYTHINKER_CODE_PLATFORM_ID; + return providerKey.slice(MANAGED_PREFIX.length) === KIMI_CODE_PLATFORM_ID; } -export function pythinkerCodeBaseUrl(): string { - return process.env['PYTHINKER_CODE_BASE_URL'] ?? DEFAULT_PYTHINKER_CODE_BASE_URL; +export function kimiCodeBaseUrl(): string { + return process.env['PYTHINKER_CODE_BASE_URL'] ?? DEFAULT_KIMI_CODE_BASE_URL; } -export function pythinkerCodeUsageUrl(): string { - return `${pythinkerCodeBaseUrl().replace(/\/+$/, '')}/usages`; +export function kimiCodeUsageUrl(): string { + return `${kimiCodeBaseUrl().replace(/\/+$/, '')}/usages`; } export interface UsageRow { @@ -219,7 +219,7 @@ export async function fetchManagedUsage( status === 401 ? 'Authorization failed. Please check your API key (try /login).' : status === 404 - ? 'Usage endpoint not available. Try Pythinker For Coding.' + ? 'Usage endpoint not available. Try Kimi For Coding.' : `Failed to fetch usage: HTTP ${String(status)}`; return { kind: 'error', status, message: await readApiErrorMessage(res, hint) }; } diff --git a/packages/oauth/src/open-platform.ts b/packages/oauth/src/open-platform.ts index 03c4e535..5ada7d12 100644 --- a/packages/oauth/src/open-platform.ts +++ b/packages/oauth/src/open-platform.ts @@ -1,12 +1,12 @@ import { readApiErrorMessage } from './api-error'; import { isRecord } from './utils'; -import { parseSupportsThinkingType } from './managed-pythinker-code'; +import { parseSupportsThinkingType } from './managed-kimi-code'; import type { - ManagedPythinkerCodeModelInfo, - ManagedPythinkerConfigShape, -} from './managed-pythinker-code'; + ManagedKimiCodeModelInfo, + ManagedKimiConfigShape, +} from './managed-kimi-code'; -export type { ManagedPythinkerConfigShape }; +export type { ManagedKimiConfigShape }; export type LoginPlatformProviderType = 'pythinker' | 'openai' | 'anthropic' | 'openai_responses'; @@ -104,7 +104,7 @@ export function isOpenPlatformId(id: string): boolean { function toModelInfo( item: unknown, platform: OpenPlatformDefinition, -): ManagedPythinkerCodeModelInfo | undefined { +): ManagedKimiCodeModelInfo | undefined { if (!isRecord(item) || typeof item['id'] !== 'string' || item['id'].length === 0) { return undefined; } @@ -139,7 +139,7 @@ function toModelInfo( * Derives kosong capability strings from a model info entry; undefined when * the model declares no capabilities. */ -export function capabilitiesForModel(model: ManagedPythinkerCodeModelInfo): string[] | undefined { +export function capabilitiesForModel(model: ManagedKimiCodeModelInfo): string[] | undefined { const caps = new Set(); // supports_thinking_type is the full three-state declaration and wins over // the legacy supports_reasoning boolean; absent (older servers) falls back. @@ -185,7 +185,7 @@ export async function fetchOpenPlatformModels( apiKey: string, fetchImpl: typeof fetch = fetch, signal?: AbortSignal, -): Promise { +): Promise { const baseUrl = platform.baseUrl; if (baseUrl === undefined || baseUrl.length === 0) { throw new Error(`Platform "${platform.id}" has no baseUrl for remote model listing.`); @@ -209,7 +209,7 @@ export async function fetchOpenPlatformModels( } return payload['data'] .map((item) => toModelInfo(item, platform)) - .filter((item): item is ManagedPythinkerCodeModelInfo => item !== undefined); + .filter((item): item is ManagedKimiCodeModelInfo => item !== undefined); } /** @@ -217,9 +217,9 @@ export async function fetchOpenPlatformModels( * prefixes; returns the full list when no prefixes are configured. */ export function filterModelsByPrefix( - models: ManagedPythinkerCodeModelInfo[], + models: ManagedKimiCodeModelInfo[], platform: OpenPlatformDefinition, -): ManagedPythinkerCodeModelInfo[] { +): ManagedKimiCodeModelInfo[] { if (!platform.allowedPrefixes || platform.allowedPrefixes.length === 0) { return models; } @@ -238,11 +238,11 @@ export interface ApplyOpenPlatformResult { * default model to the selected one. */ export function applyOpenPlatformConfig( - config: ManagedPythinkerConfigShape, + config: ManagedKimiConfigShape, options: { readonly platform: OpenPlatformDefinition; - readonly models: readonly ManagedPythinkerCodeModelInfo[]; - readonly selectedModel: ManagedPythinkerCodeModelInfo; + readonly models: readonly ManagedKimiCodeModelInfo[]; + readonly selectedModel: ManagedKimiCodeModelInfo; readonly thinking: boolean; readonly apiKey: string; }, @@ -286,7 +286,7 @@ export function applyOpenPlatformConfig( * that pointed at them. */ export function removeOpenPlatformConfig( - config: ManagedPythinkerConfigShape, + config: ManagedKimiConfigShape, platformId: string, ): void { delete config.providers[platformId]; diff --git a/packages/oauth/src/openai-codex-oauth.ts b/packages/oauth/src/openai-codex-oauth.ts index a13ea9d3..5067fd74 100644 --- a/packages/oauth/src/openai-codex-oauth.ts +++ b/packages/oauth/src/openai-codex-oauth.ts @@ -3,10 +3,10 @@ import { createServer, type Server } from 'node:http'; import { readApiErrorMessage } from './api-error'; import type { - ManagedPythinkerCodeModelInfo, - ManagedPythinkerConfigShape, -} from './managed-pythinker-code'; -import { parseSupportsThinkingType } from './managed-pythinker-code'; + ManagedKimiCodeModelInfo, + ManagedKimiConfigShape, +} from './managed-kimi-code'; +import { parseSupportsThinkingType } from './managed-kimi-code'; import { renderOAuthErrorPage, renderOpenAICodexOAuthSuccessPage } from './oauth-pages'; import { capabilitiesForModel } from './open-platform'; import { isRecord } from './utils'; @@ -442,7 +442,7 @@ function readCodexFastMode(item: Record): boolean { }); } -function toCodexModelInfo(item: unknown): ManagedPythinkerCodeModelInfo | undefined { +function toCodexModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { if (!isRecord(item)) return undefined; const id = @@ -506,7 +506,7 @@ function toCodexModelInfo(item: unknown): ManagedPythinkerCodeModelInfo | undefi }; } -function parseCodexModelsPayload(payload: unknown): ManagedPythinkerCodeModelInfo[] { +function parseCodexModelsPayload(payload: unknown): ManagedKimiCodeModelInfo[] { if (!isRecord(payload)) { throw new Error(`Unexpected models response for ${CODEX_BASE_URL}.`); } @@ -522,7 +522,7 @@ function parseCodexModelsPayload(payload: unknown): ManagedPythinkerCodeModelInf return rawModels .map((item) => toCodexModelInfo(item)) - .filter((item): item is ManagedPythinkerCodeModelInfo => item !== undefined); + .filter((item): item is ManagedKimiCodeModelInfo => item !== undefined); } /** @@ -531,7 +531,7 @@ function parseCodexModelsPayload(payload: unknown): ManagedPythinkerCodeModelInf */ export async function fetchOpenAICodexModels( options: FetchOpenAICodexModelsOptions, -): Promise { +): Promise { const fetchImpl = options.fetchImpl ?? fetch; const modelsUrl = `${CODEX_BASE_URL}/models?client_version=${encodeURIComponent(OPENAI_CODEX_CLI_CLIENT_VERSION)}`; const response = await fetchImpl(modelsUrl, { @@ -565,13 +565,13 @@ export interface ApplyOpenAICodexOAuthResult { * reasoning effort into the config in place. */ export function applyOpenAICodexOAuthConfig( - config: ManagedPythinkerConfigShape, + config: ManagedKimiConfigShape, options: { readonly accessToken: string; readonly accountId?: string | undefined; readonly refreshToken?: string | undefined; - readonly models: readonly ManagedPythinkerCodeModelInfo[]; - readonly selectedModel: ManagedPythinkerCodeModelInfo; + readonly models: readonly ManagedKimiCodeModelInfo[]; + readonly selectedModel: ManagedKimiCodeModelInfo; readonly thinking?: boolean | undefined; }, ): ApplyOpenAICodexOAuthResult { diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index 904097c6..2eec1f62 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -1,26 +1,26 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; -import { PYTHINKER_CODE_FLOW_CONFIG } from './constants'; +import { KIMI_CODE_FLOW_CONFIG } from './constants'; import { OAuthUnauthorizedError } from './errors'; import { assertPythinkerHostIdentity, createPythinkerDeviceHeaders, type PythinkerHostIdentity } from './identity'; import { fetchSubmitFeedback, - pythinkerCodeFeedbackUrl, + kimiCodeFeedbackUrl, type FetchSubmitFeedbackResult, type SubmitFeedbackBody, } from './managed-feedback'; import { - PYTHINKER_CODE_OAUTH_KEY, - PYTHINKER_CODE_PROVIDER_NAME, - provisionManagedPythinkerCodeConfig, - resolvePythinkerCodeOAuthKey, - type ManagedPythinkerCodeProvisionResult, - type ManagedPythinkerConfigAdapter, -} from './managed-pythinker-code'; + KIMI_CODE_OAUTH_KEY, + KIMI_CODE_PROVIDER_NAME, + provisionManagedKimiCodeConfig, + resolveKimiCodeOAuthKey, + type ManagedKimiCodeProvisionResult, + type ManagedKimiConfigAdapter, +} from './managed-kimi-code'; import { fetchManagedUsage, - pythinkerCodeUsageUrl, + kimiCodeUsageUrl, type FetchManagedUsageError, type ParsedManagedUsage, } from './managed-usage'; @@ -47,7 +47,7 @@ export interface PythinkerOAuthToolkitOptions { readonly credentialsDir?: string | undefined; readonly storage?: TokenStorage | undefined; readonly flowConfig?: OAuthFlowConfig | undefined; - readonly configAdapter?: ManagedPythinkerConfigAdapter | undefined; + readonly configAdapter?: ManagedKimiConfigAdapter | undefined; readonly fetchImpl?: typeof fetch | undefined; readonly now?: OAuthManagerOptions['now']; readonly sleep?: OAuthManagerOptions['sleep']; @@ -71,7 +71,7 @@ export interface PythinkerOAuthTokenRef { export interface PythinkerOAuthLoginResult { readonly providerName: string; readonly ok: true; - readonly provision?: ManagedPythinkerCodeProvisionResult | undefined; + readonly provision?: ManagedKimiCodeProvisionResult | undefined; } export interface PythinkerOAuthLogoutResult { @@ -92,7 +92,7 @@ export class PythinkerOAuthToolkit { private readonly identity: PythinkerHostIdentity | undefined; private readonly storage: TokenStorage; private readonly flowConfig: OAuthFlowConfig; - private readonly configAdapter: ManagedPythinkerConfigAdapter | undefined; + private readonly configAdapter: ManagedKimiConfigAdapter | undefined; private readonly fetchImpl: typeof fetch | undefined; private readonly managerOptions: Pick< OAuthManagerOptions, @@ -106,7 +106,7 @@ export class PythinkerOAuthToolkit { this.homeDir = options.homeDir ?? defaultPythinkerHome(); const credentialsDir = options.credentialsDir ?? join(this.homeDir, 'credentials'); this.storage = options.storage ?? new FileTokenStorage(credentialsDir); - this.flowConfig = options.flowConfig ?? PYTHINKER_CODE_FLOW_CONFIG; + this.flowConfig = options.flowConfig ?? KIMI_CODE_FLOW_CONFIG; this.configAdapter = options.configAdapter; this.fetchImpl = options.fetchImpl; this.managerOptions = { @@ -122,7 +122,7 @@ export class PythinkerOAuthToolkit { providerName?: string | undefined, oauthRef?: PythinkerOAuthTokenRef | undefined, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(oauthRef); const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); return { @@ -139,7 +139,7 @@ export class PythinkerOAuthToolkit { providerName?: string | undefined, options: PythinkerOAuthLoginOptions = {}, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(options.oauthRef, options.oauthHost); const oauthKey = options.oauthRef?.key ?? this.defaultOAuthKey(options.baseUrl, oauthHost); const manager = this.managerFor(name, oauthKey, oauthHost); @@ -168,10 +168,10 @@ export class PythinkerOAuthToolkit { const shouldProvision = options.provisionConfig ?? this.configAdapter !== undefined; const configAdapter = this.configAdapter; - let provision: ManagedPythinkerCodeProvisionResult | undefined; + let provision: ManagedKimiCodeProvisionResult | undefined; if (shouldProvision && configAdapter !== undefined) { - const provisionWithToken = (token: string): Promise => - provisionManagedPythinkerCodeConfig({ + const provisionWithToken = (token: string): Promise => + provisionManagedKimiCodeConfig({ accessToken: token, adapter: configAdapter, baseUrl: options.baseUrl, @@ -211,11 +211,11 @@ export class PythinkerOAuthToolkit { providerName?: string | undefined, oauthRef?: PythinkerOAuthTokenRef | undefined, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(oauthRef); const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); await this.managerFor(name, oauthKey, oauthHost).logout(); - if (this.configAdapter?.remove !== undefined && name === PYTHINKER_CODE_PROVIDER_NAME) { + if (this.configAdapter?.remove !== undefined && name === KIMI_CODE_PROVIDER_NAME) { const config = await this.configAdapter.read(); this.configAdapter.remove(config); await this.configAdapter.write(config); @@ -230,7 +230,7 @@ export class PythinkerOAuthToolkit { readonly oauthRef?: PythinkerOAuthTokenRef | undefined; } = {}, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(options.oauthRef); const oauthKey = options.oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); return this.managerFor(name, oauthKey, oauthHost).ensureFresh(options); @@ -240,7 +240,7 @@ export class PythinkerOAuthToolkit { providerName?: string, oauthRef?: PythinkerOAuthTokenRef, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(oauthRef); const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); return this.managerFor(name, oauthKey, oauthHost).getCachedAccessToken(); @@ -250,7 +250,7 @@ export class PythinkerOAuthToolkit { providerName?: string | undefined, oauthRef?: PythinkerOAuthTokenRef | undefined, ): BearerTokenProvider { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(oauthRef); const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); return { @@ -265,7 +265,7 @@ export class PythinkerOAuthToolkit { readonly baseUrl?: string | undefined; } = {}, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; try { const accessToken = await this.ensureFresh(name, { oauthRef: options.oauthRef ?? this.defaultOAuthRef(options.baseUrl), @@ -293,7 +293,7 @@ export class PythinkerOAuthToolkit { readonly baseUrl?: string | undefined; } = {}, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; try { const accessToken = await this.ensureFresh(name, { oauthRef: options.oauthRef ?? this.defaultOAuthRef(options.baseUrl), @@ -309,7 +309,7 @@ export class PythinkerOAuthToolkit { managerFor( providerName: string, - oauthKey = PYTHINKER_CODE_OAUTH_KEY, + oauthKey = KIMI_CODE_OAUTH_KEY, oauthHost?: string | undefined, ): OAuthManager { const storageName = resolvePythinkerTokenStorageName({ providerName, oauthKey }); @@ -345,7 +345,7 @@ export class PythinkerOAuthToolkit { baseUrl?: string | undefined, oauthHost?: string | undefined, ): string { - return resolvePythinkerCodeOAuthKey({ + return resolveKimiCodeOAuthKey({ oauthHost: oauthHost ?? this.flowConfig.oauthHost, baseUrl, }); @@ -370,13 +370,13 @@ export function resolvePythinkerTokenStorageName(input: { readonly providerName?: string | undefined; readonly oauthKey?: string | undefined; }): string { - const providerName = input.providerName ?? PYTHINKER_CODE_PROVIDER_NAME; - if (providerName !== PYTHINKER_CODE_PROVIDER_NAME) { + const providerName = input.providerName ?? KIMI_CODE_PROVIDER_NAME; + if (providerName !== KIMI_CODE_PROVIDER_NAME) { throw new Error(`No OAuth manager configured for provider "${providerName}".`); } - const key = input.oauthKey ?? PYTHINKER_CODE_OAUTH_KEY; - if (key === 'pythinker-code' || key === PYTHINKER_CODE_OAUTH_KEY) return 'pythinker-code'; + const key = input.oauthKey ?? KIMI_CODE_OAUTH_KEY; + if (key === 'kimi-code' || key === KIMI_CODE_OAUTH_KEY) return 'kimi-code'; const prefix = 'oauth/'; if (key.startsWith(prefix) && key.slice(prefix.length).length > 0) { @@ -394,12 +394,12 @@ function defaultPythinkerHome(): string { } function managedUsageUrl(baseUrl: string | undefined): string { - if (baseUrl === undefined) return pythinkerCodeUsageUrl(); + if (baseUrl === undefined) return kimiCodeUsageUrl(); return `${baseUrl.replace(/\/+$/, '')}/usages`; } function managedFeedbackUrl(baseUrl: string | undefined): string { - if (baseUrl === undefined) return pythinkerCodeFeedbackUrl(); + if (baseUrl === undefined) return kimiCodeFeedbackUrl(); return `${baseUrl.replace(/\/+$/, '')}/feedback`; } diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index dcb38d07..c370c5d2 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -36,7 +36,7 @@ export interface DeviceAuthorization { /** OAuth flow endpoint + client configuration. */ export interface OAuthFlowConfig { - /** Logical provider name for storage (e.g. "pythinker-code"). */ + /** Logical provider name for storage (e.g. "kimi-code"). */ readonly name: string; /** Base URL of the OAuth server, no trailing slash. */ readonly oauthHost: string; diff --git a/packages/oauth/test/custom-registry.test.ts b/packages/oauth/test/custom-registry.test.ts index f8840c88..d894fd9e 100644 --- a/packages/oauth/test/custom-registry.test.ts +++ b/packages/oauth/test/custom-registry.test.ts @@ -11,7 +11,7 @@ import { removeCustomRegistryProvider, type CustomRegistryProviderEntry, type CustomRegistrySource, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '../src/custom-registry'; function makeKokubResponseBody(): Record { @@ -184,7 +184,7 @@ describe('fetchCustomRegistry', () => { describe('applyCustomRegistryProvider', () => { it('writes provider + model aliases for a kokub-shaped entry with default fallbacks', () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'registry_chat-completions', name: 'Sample Registry (chat completions)', @@ -224,7 +224,7 @@ describe('applyCustomRegistryProvider', () => { }); it('falls back to the model id for displayName when name is absent', () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'demo', name: 'Demo', @@ -245,7 +245,7 @@ describe('applyCustomRegistryProvider', () => { }); it('derives rich capabilities and limit-based context size when rich fields are present', () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'rich', name: 'Rich Provider', @@ -279,7 +279,7 @@ describe('applyCustomRegistryProvider', () => { }); it('clears stale aliases for the same provider before re-populating', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { 'registry_chat-completions': { type: 'openai', @@ -323,7 +323,7 @@ describe('applyCustomRegistryProvider', () => { describe('removeCustomRegistryProvider', () => { it('removes the provider and every alias for it, and clears matching defaultModel', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { 'registry_chat-completions': { type: 'openai', @@ -359,7 +359,7 @@ describe('removeCustomRegistryProvider', () => { }); it('leaves defaultModel intact when it belongs to another provider', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { 'registry_chat-completions': { type: 'openai', @@ -401,7 +401,7 @@ describe('applyCustomRegistryEntries', () => { c: { id: 'c', name: 'C', api: 'https://c.test/v1', type: 'openai', models: { 'm1': { id: 'm1' } } }, }; - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; applyCustomRegistryEntries(config, entries, source); applyCustomRegistryEntries(config, entries, source); @@ -417,7 +417,7 @@ describe('applyCustomRegistryEntries', () => { url: 'https://registry.example.test/api.json', apiKey: 'sk-new', }; - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { x: { type: 'openai', baseUrl: 'https://x-old.test/v1', apiKey: 'sk-old' }, }, @@ -453,7 +453,7 @@ describe('applyCustomRegistryEntries', () => { url: 'https://registry.example.test/api.json', apiKey: 'sk-new', }; - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { x: { type: 'openai', baseUrl: 'https://x-old.test/v1', apiKey: 'sk-old' }, }, @@ -507,7 +507,7 @@ describe('applyCustomRegistryEntries', () => { b: { id: 'b', name: 'B', api: 'https://b.test/v1', type: 'openai', models: { m1: { id: 'm1' } } }, }; - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { // Provider from an unrelated source — must not be touched. keepme: { @@ -564,7 +564,7 @@ describe('applyCustomRegistryEntries', () => { apiKey: 'sk-b', }; - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; applyCustomRegistryEntries( config, { diff --git a/packages/oauth/test/managed-feedback.test.ts b/packages/oauth/test/managed-feedback.test.ts index 7c7cdd6f..ca61ee36 100644 --- a/packages/oauth/test/managed-feedback.test.ts +++ b/packages/oauth/test/managed-feedback.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { fetchSubmitFeedback, - pythinkerCodeFeedbackUrl, + kimiCodeFeedbackUrl, type SubmitFeedbackBody, } from '../src/managed-feedback'; @@ -19,14 +19,14 @@ const SAMPLE_BODY: SubmitFeedbackBody = { model: 'pythinker-code/pythinker-for-coding', }; -describe('pythinkerCodeFeedbackUrl', () => { +describe('kimiCodeFeedbackUrl', () => { it('appends /feedback to the default base URL', () => { - expect(pythinkerCodeFeedbackUrl()).toBe('https://api.kimi.com/coding/v1/feedback'); + expect(kimiCodeFeedbackUrl()).toBe('https://api.kimi.com/coding/v1/feedback'); }); it('honours PYTHINKER_CODE_BASE_URL and trims trailing slashes', () => { vi.stubEnv('PYTHINKER_CODE_BASE_URL', 'https://example.test/v9///'); - expect(pythinkerCodeFeedbackUrl()).toBe('https://example.test/v9/feedback'); + expect(kimiCodeFeedbackUrl()).toBe('https://example.test/v9/feedback'); }); }); diff --git a/packages/oauth/test/managed-pythinker-code.test.ts b/packages/oauth/test/managed-kimi-code.test.ts similarity index 74% rename from packages/oauth/test/managed-pythinker-code.test.ts rename to packages/oauth/test/managed-kimi-code.test.ts index 6bc1f14c..b4abc784 100644 --- a/packages/oauth/test/managed-pythinker-code.test.ts +++ b/packages/oauth/test/managed-kimi-code.test.ts @@ -1,20 +1,20 @@ import { describe, expect, it, vi } from 'vitest'; import { - applyManagedPythinkerCodeLogoutConfig, - applyManagedPythinkerCodeConfig, - clearManagedPythinkerCodeConfig, - fetchManagedPythinkerCodeModels, - PYTHINKER_CODE_OAUTH_KEY, - PYTHINKER_CODE_PROVIDER_NAME, - ManagedPythinkerCodeModelsAuthError, - provisionManagedPythinkerCodeConfig, - resolvePythinkerCodeLoginAuth, - resolvePythinkerCodeOAuthKey, - resolvePythinkerCodeOAuthRef, - resolvePythinkerCodeRuntimeAuth, - type ManagedPythinkerConfigShape, -} from '../src/managed-pythinker-code'; + applyManagedKimiCodeLogoutConfig, + applyManagedKimiCodeConfig, + clearManagedKimiCodeConfig, + fetchManagedKimiCodeModels, + KIMI_CODE_OAUTH_KEY, + KIMI_CODE_PROVIDER_NAME, + ManagedKimiCodeModelsAuthError, + provisionManagedKimiCodeConfig, + resolveKimiCodeLoginAuth, + resolveKimiCodeOAuthKey, + resolveKimiCodeOAuthRef, + resolveKimiCodeRuntimeAuth, + type ManagedKimiConfigShape, +} from '../src/managed-kimi-code'; import { OAuthUnauthorizedError } from '../src/errors'; function makeModelsResponse(): Response { @@ -43,26 +43,26 @@ function makeModelsResponse(): Response { ); } -describe('provisionManagedPythinkerCodeConfig', () => { +describe('provisionManagedKimiCodeConfig', () => { it('keeps the legacy credential key for the default production environment', () => { expect( - resolvePythinkerCodeOAuthKey({ + resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.kimi.com/', baseUrl: 'https://api.kimi.com/coding/v1/', }), - ).toBe(PYTHINKER_CODE_OAUTH_KEY); + ).toBe(KIMI_CODE_OAUTH_KEY); }); it('scopes credential keys for non-default OAuth hosts and API base URLs', () => { - const devKey = resolvePythinkerCodeOAuthKey({ + const devKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl: 'https://api.dev.example.test/coding/v1', }); - expect(devKey).not.toBe(PYTHINKER_CODE_OAUTH_KEY); - expect(devKey).toMatch(/^oauth\/pythinker-code-env-[a-f0-9]{16}$/); + expect(devKey).not.toBe(KIMI_CODE_OAUTH_KEY); + expect(devKey).toMatch(/^oauth\/kimi-code-env-[a-f0-9]{16}$/); expect( - resolvePythinkerCodeOAuthKey({ + resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test/', baseUrl: 'https://api.dev.example.test/coding/v1/', }), @@ -71,20 +71,20 @@ describe('provisionManagedPythinkerCodeConfig', () => { it('derives a full OAuth ref whose key and persisted host stay in sync', () => { // Default environment collapses to the legacy ref (no persisted host), so - // existing production credentials keep resolving to `pythinker-code.json`. + // existing production credentials keep resolving to `kimi-code.json`. expect( - resolvePythinkerCodeOAuthRef({ + resolveKimiCodeOAuthRef({ oauthHost: 'https://auth.kimi.com/', baseUrl: 'https://api.kimi.com/coding/v1/', }), - ).toEqual({ storage: 'file', key: PYTHINKER_CODE_OAUTH_KEY, oauthHost: undefined }); + ).toEqual({ storage: 'file', key: KIMI_CODE_OAUTH_KEY, oauthHost: undefined }); - const defaultAuthCustomApiRef = resolvePythinkerCodeOAuthRef({ + const defaultAuthCustomApiRef = resolveKimiCodeOAuthRef({ baseUrl: 'https://api.example.test/coding/v1', }); expect(defaultAuthCustomApiRef).toEqual({ storage: 'file', - key: resolvePythinkerCodeOAuthKey({ + key: resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.kimi.com', baseUrl: 'https://api.example.test/coding/v1', }), @@ -93,13 +93,13 @@ describe('provisionManagedPythinkerCodeConfig', () => { // A non-default environment yields a scoped key AND the normalized host, // both derived from the same input — login and runtime cannot drift apart. - const devRef = resolvePythinkerCodeOAuthRef({ + const devRef = resolveKimiCodeOAuthRef({ oauthHost: 'https://auth.dev.example.test/', baseUrl: 'https://api.dev.example.test/coding/v1', }); expect(devRef).toEqual({ storage: 'file', - key: resolvePythinkerCodeOAuthKey({ + key: resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl: 'https://api.dev.example.test/coding/v1', }), @@ -111,11 +111,11 @@ describe('provisionManagedPythinkerCodeConfig', () => { const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; const envBaseUrl = 'https://api.env.example.test/coding/v1/'; const envOauthHost = 'https://auth.env.example.test/'; - const configuredOAuthRef = resolvePythinkerCodeOAuthRef({ + const configuredOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: configuredBaseUrl, }); - const auth = resolvePythinkerCodeRuntimeAuth({ + const auth = resolveKimiCodeRuntimeAuth({ configuredBaseUrl, configuredOAuthRef, env: { @@ -127,7 +127,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { expect(auth.baseUrl).toBe('https://api.env.example.test/coding/v1'); expect(auth.oauthRef).toEqual({ storage: 'file', - key: resolvePythinkerCodeOAuthKey({ + key: resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.env.example.test', baseUrl: 'https://api.env.example.test/coding/v1', }), @@ -139,7 +139,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { const baseUrl = 'https://api.dev.example.test/coding/v1'; const configuredOAuthRef = { storage: 'keyring' as const, - key: resolvePythinkerCodeOAuthKey({ + key: resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl, }), @@ -147,7 +147,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { }; expect( - resolvePythinkerCodeRuntimeAuth({ + resolveKimiCodeRuntimeAuth({ configuredBaseUrl: baseUrl, configuredOAuthRef, env: {}, @@ -160,10 +160,10 @@ describe('provisionManagedPythinkerCodeConfig', () => { it('resolves login auth without reusing persisted refs under explicit or env overrides', () => { const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; - const configuredOAuthRef = resolvePythinkerCodeOAuthRef({ baseUrl: configuredBaseUrl }); + const configuredOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: configuredBaseUrl }); expect( - resolvePythinkerCodeLoginAuth({ + resolveKimiCodeLoginAuth({ configuredBaseUrl, configuredOAuthRef, requestedBaseUrl: 'https://api.requested.example.test/coding/v1/', @@ -175,7 +175,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); expect( - resolvePythinkerCodeLoginAuth({ + resolveKimiCodeLoginAuth({ configuredBaseUrl, configuredOAuthRef, env: {}, @@ -188,7 +188,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('writes the managed provider, models, services, and default model through an adapter', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', @@ -197,8 +197,8 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }, models: { - 'pythinker-code/stale': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/stale': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'stale', }, 'custom-default': { @@ -210,20 +210,20 @@ describe('provisionManagedPythinkerCodeConfig', () => { const write = vi.fn(); const fetchMock = vi.fn(async () => makeModelsResponse()); - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: fetchMock as unknown as typeof fetch, adapter: { configPath: '/tmp/config.toml', read: () => config, write, - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); expect(result).toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, - defaultModel: 'pythinker-code/pythinker-for-coding', + providerName: KIMI_CODE_PROVIDER_NAME, + defaultModel: 'kimi-code/pythinker-for-coding', defaultThinking: true, configPath: '/tmp/config.toml', }); @@ -249,39 +249,39 @@ describe('provisionManagedPythinkerCodeConfig', () => { apiKey: 'sk-existing', }); expect(config.models?.['custom-default']?.provider).toBe('custom'); - expect(config.models?.['pythinker-code/stale']).toBeUndefined(); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.models?.['kimi-code/stale']).toBeUndefined(); + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ type: 'pythinker', baseUrl: 'https://api.kimi.com/coding/v1', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }); - expect(config.models?.['pythinker-code/pythinker-for-coding']).toMatchObject({ - provider: PYTHINKER_CODE_PROVIDER_NAME, + expect(config.models?.['kimi-code/pythinker-for-coding']).toMatchObject({ + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['thinking', 'image_in', 'video_in', 'tool_use'], displayName: 'Pythinker for Coding', }); - expect(config.models?.['pythinker-code/pythinker-k2.5']?.capabilities).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-k2.5']?.capabilities).toBeUndefined(); expect(config.services?.pythoughtsSearch).toMatchObject({ baseUrl: 'https://api.kimi.com/coding/v1/search', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }); expect(Object.keys(config.services ?? {})).toEqual(['pythoughtsSearch', 'pythoughtsFetch']); }); it('writes scoped OAuth refs when provisioning against a non-default environment', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: {}, }; - const oauthKey = resolvePythinkerCodeOAuthKey({ + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl: 'https://api.dev.example.test/coding/v1', }); - await provisionManagedPythinkerCodeConfig({ + await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', baseUrl: 'https://api.dev.example.test/coding/v1', oauthKey, @@ -290,11 +290,11 @@ describe('provisionManagedPythinkerCodeConfig', () => { adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ baseUrl: 'https://api.dev.example.test/coding/v1', oauth: { storage: 'file', @@ -315,24 +315,24 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('persists the default OAuth host when only the API base URL is scoped', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: {}, }; const baseUrl = 'https://api.example.test/coding/v1'; - const oauthKey = resolvePythinkerCodeOAuthKey({ baseUrl }); + const oauthKey = resolveKimiCodeOAuthKey({ baseUrl }); - await provisionManagedPythinkerCodeConfig({ + await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', baseUrl, fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ baseUrl, oauth: { storage: 'file', @@ -343,14 +343,14 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('preserves an existing valid default model during refresh', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', apiKey: 'sk-existing', baseUrl: 'https://example.test/v1', }, - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', }, @@ -363,22 +363,22 @@ describe('provisionManagedPythinkerCodeConfig', () => { model: 'custom-model', maxContextSize: 1000, }, - 'pythinker-code/stale': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/stale': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'stale', maxContextSize: 1000, }, }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); @@ -386,22 +386,22 @@ describe('provisionManagedPythinkerCodeConfig', () => { expect(result.defaultThinking).toBe(false); expect(config.defaultModel).toBe('custom-default'); expect(config.defaultThinking).toBe(false); - expect(config.models?.['pythinker-code/stale']).toBeUndefined(); - expect(config.models?.['pythinker-code/pythinker-for-coding']?.displayName).toBe('Pythinker for Coding'); + expect(config.models?.['kimi-code/stale']).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-for-coding']?.displayName).toBe('Pythinker for Coding'); }); it('infers default_thinking from fresh managed model capabilities', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 1000, capabilities: [], @@ -409,24 +409,24 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(result.defaultModel).toBe('pythinker-code/pythinker-for-coding'); + expect(result.defaultModel).toBe('kimi-code/pythinker-for-coding'); expect(result.defaultThinking).toBe(true); expect(config.defaultThinking).toBe(true); }); it('preserves explicit default_thinking when preserving a custom default without capabilities', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', @@ -444,14 +444,14 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); @@ -461,7 +461,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('defaults default_thinking to false when a preserved custom default has no signal', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', @@ -478,14 +478,14 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); @@ -495,7 +495,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('does not infer default_thinking from preserved custom default capabilities', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', @@ -513,14 +513,14 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); @@ -530,7 +530,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('keeps default_thinking off even when preserved custom default has thinking capability', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', @@ -548,14 +548,14 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); @@ -565,45 +565,45 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('falls back to the first fetched model when the preserved default was removed', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', }, }, - defaultModel: 'pythinker-code/stale', + defaultModel: 'kimi-code/stale', defaultThinking: false, models: { - 'pythinker-code/stale': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/stale': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'stale', maxContextSize: 1000, }, }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(result.defaultModel).toBe('pythinker-code/pythinker-for-coding'); + expect(result.defaultModel).toBe('kimi-code/pythinker-for-coding'); expect(result.defaultThinking).toBe(false); - expect(config.defaultModel).toBe('pythinker-code/pythinker-for-coding'); + expect(config.defaultModel).toBe('kimi-code/pythinker-for-coding'); expect(config.defaultThinking).toBe(false); }); it('removes managed provider, models, services, and default model on logout', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', }, @@ -612,11 +612,11 @@ describe('provisionManagedPythinkerCodeConfig', () => { apiKey: 'sk-existing', }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', defaultThinking: true, models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 262144, }, @@ -632,14 +632,14 @@ describe('provisionManagedPythinkerCodeConfig', () => { customService: { baseUrl: 'https://service.example.test' }, }, raw: { - default_model: 'pythinker-code/pythinker-for-coding', + default_model: 'kimi-code/pythinker-for-coding', providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { type: 'pythinker' }, + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker' }, custom: { type: 'pythinker' }, }, models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', }, 'custom-default': { @@ -654,12 +654,12 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }; - applyManagedPythinkerCodeLogoutConfig(config); + applyManagedKimiCodeLogoutConfig(config); expect(config.defaultModel).toBeUndefined(); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toBeUndefined(); + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeUndefined(); expect(config.providers['custom']).toBeDefined(); - expect(config.models?.['pythinker-code/pythinker-for-coding']).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-for-coding']).toBeUndefined(); expect(config.models?.['custom-default']).toBeDefined(); expect(config.services?.pythoughtsSearch).toBeUndefined(); expect(config.services?.pythoughtsFetch).toBeUndefined(); @@ -680,7 +680,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { ) as unknown as typeof fetch; await expect( - fetchManagedPythinkerCodeModels({ + fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl, }), @@ -697,7 +697,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { ) as unknown as typeof fetch; await expect( - fetchManagedPythinkerCodeModels({ + fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl, }), @@ -719,7 +719,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { ) as unknown as typeof fetch; await expect( - fetchManagedPythinkerCodeModels({ + fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl, }), @@ -743,17 +743,17 @@ describe('provisionManagedPythinkerCodeConfig', () => { ), ) as unknown as typeof fetch; - const promise = fetchManagedPythinkerCodeModels({ + const promise = fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', baseUrl: 'https://api.dev.example.test/coding/v1', fetchImpl, }); await expect(promise).rejects.toThrow( - "Pythinker Code models endpoint https://api.dev.example.test/coding/v1 rejected OAuth credentials: We're unable to verify your membership benefits at this time. Please ensure your membership is active.", + "Kimi Code models endpoint https://api.dev.example.test/coding/v1 rejected OAuth credentials: We're unable to verify your membership benefits at this time. Please ensure your membership is active.", ); await expect( - fetchManagedPythinkerCodeModels({ + fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', baseUrl: 'https://api.dev.example.test/coding/v1', fetchImpl, @@ -763,36 +763,36 @@ describe('provisionManagedPythinkerCodeConfig', () => { baseUrl: 'https://api.dev.example.test/coding/v1', }); await expect( - fetchManagedPythinkerCodeModels({ + fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl, }), ).rejects.toBeInstanceOf(OAuthUnauthorizedError); await expect( - fetchManagedPythinkerCodeModels({ + fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl, }), - ).rejects.toBeInstanceOf(ManagedPythinkerCodeModelsAuthError); + ).rejects.toBeInstanceOf(ManagedKimiCodeModelsAuthError); }); it('clears managed provider, models, default model, and services on logout', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, custom: { type: 'pythinker', apiKey: 'sk-existing', }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 262144, }, @@ -806,30 +806,30 @@ describe('provisionManagedPythinkerCodeConfig', () => { pythoughtsSearch: { baseUrl: 'https://api.kimi.com/coding/v1/search', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, pythoughtsFetch: { baseUrl: 'https://api.kimi.com/coding/v1/fetch', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, otherService: { baseUrl: 'https://service.example.test' }, }, }; - const result = clearManagedPythinkerCodeConfig(config); + const result = clearManagedKimiCodeConfig(config); expect(result).toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, removedProvider: true, - removedModels: ['pythinker-code/pythinker-for-coding'], + removedModels: ['kimi-code/pythinker-for-coding'], defaultModelCleared: true, removedServices: ['pythoughtsSearch', 'pythoughtsFetch'], }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toBeUndefined(); + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeUndefined(); expect(config.providers['custom']).toMatchObject({ apiKey: 'sk-existing' }); expect(config.defaultModel).toBeUndefined(); - expect(config.models?.['pythinker-code/pythinker-for-coding']).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-for-coding']).toBeUndefined(); expect(config.models?.['custom-default']).toMatchObject({ provider: 'custom' }); expect(config.services?.pythoughtsSearch).toBeUndefined(); expect(config.services?.pythoughtsFetch).toBeUndefined(); @@ -874,7 +874,7 @@ describe('supports_thinking_type', () => { } it('parses supports_thinking_type from the models endpoint', async () => { - const models = await fetchManagedPythinkerCodeModels({ + const models = await fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, }); @@ -885,13 +885,13 @@ describe('supports_thinking_type', () => { }); it('leaves supportsThinkingType undefined when the field is absent or invalid', async () => { - const absent = await fetchManagedPythinkerCodeModels({ + const absent = await fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, }); expect(absent[0]?.supportsThinkingType).toBeUndefined(); - const invalid = await fetchManagedPythinkerCodeModels({ + const invalid = await fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl: vi.fn( async () => @@ -914,20 +914,20 @@ describe('supports_thinking_type', () => { }); it('maps the three states onto capabilities, overriding supports_reasoning', async () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; - await provisionManagedPythinkerCodeConfig({ + await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); // 'only' → thinking locked on. - expect(config.models?.['pythinker-code/pythinker-for-coding']?.capabilities).toEqual([ + expect(config.models?.['kimi-code/pythinker-for-coding']?.capabilities).toEqual([ 'thinking', 'always_thinking', 'image_in', @@ -935,18 +935,18 @@ describe('supports_thinking_type', () => { 'tool_use', ]); // 'no' → no thinking capability despite supports_reasoning=true. - expect(config.models?.['pythinker-code/pythinker-plain']?.capabilities).toEqual(['tool_use']); + expect(config.models?.['kimi-code/pythinker-plain']?.capabilities).toEqual(['tool_use']); // 'both' → plain toggleable thinking. - expect(config.models?.['pythinker-code/pythinker-toggle']?.capabilities).toEqual([ + expect(config.models?.['kimi-code/pythinker-toggle']?.capabilities).toEqual([ 'thinking', 'tool_use', ]); }); it('parses supported_reasoning_efforts and writes supportEfforts onto model aliases', async () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn( async () => @@ -976,54 +976,54 @@ describe('supports_thinking_type', () => { adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); expect(result.models[0]?.supportedReasoningEfforts).toEqual(['low', 'medium', 'high', 'max']); expect(result.models[1]?.supportedReasoningEfforts).toBeUndefined(); expect(result.models[2]?.supportedReasoningEfforts).toBeUndefined(); - expect(config.models?.['pythinker-code/pythinker-for-coding']?.supportEfforts).toEqual([ + expect(config.models?.['kimi-code/pythinker-for-coding']?.supportEfforts).toEqual([ 'low', 'medium', 'high', 'max', ]); - expect(config.models?.['pythinker-code/pythinker-plain']?.supportEfforts).toBeUndefined(); - expect(config.models?.['pythinker-code/pythinker-invalid']?.supportEfforts).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-plain']?.supportEfforts).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-invalid']?.supportEfforts).toBeUndefined(); }); it('forces default thinking on when the selected default model is thinking-only', async () => { - const config: ManagedPythinkerConfigShape = { providers: {}, defaultThinking: false }; + const config: ManagedKimiConfigShape = { providers: {}, defaultThinking: false }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(result.defaultModel).toBe('pythinker-code/pythinker-for-coding'); + expect(result.defaultModel).toBe('kimi-code/pythinker-for-coding'); expect(result.defaultThinking).toBe(true); expect(config.defaultThinking).toBe(true); }); it('forces default thinking on when preserving a thinking-only managed default', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', defaultThinking: false, models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['thinking'], @@ -1031,35 +1031,35 @@ describe('supports_thinking_type', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(result.defaultModel).toBe('pythinker-code/pythinker-for-coding'); + expect(result.defaultModel).toBe('kimi-code/pythinker-for-coding'); expect(result.defaultThinking).toBe(true); expect(config.defaultThinking).toBe(true); }); it('forces default thinking off when preserving a no-thinking managed default', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', }, }, - defaultModel: 'pythinker-code/pythinker-plain', + defaultModel: 'kimi-code/pythinker-plain', defaultThinking: true, models: { - 'pythinker-code/pythinker-plain': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-plain': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-plain', maxContextSize: 128000, capabilities: ['thinking'], @@ -1067,24 +1067,24 @@ describe('supports_thinking_type', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(result.defaultModel).toBe('pythinker-code/pythinker-plain'); + expect(result.defaultModel).toBe('kimi-code/pythinker-plain'); expect(result.defaultThinking).toBe(false); expect(config.defaultThinking).toBe(false); }); it('keeps a preserved non-managed default thinking selection untouched', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', @@ -1102,14 +1102,14 @@ describe('supports_thinking_type', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index 22175e61..09cfc7c9 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -4,7 +4,7 @@ import { fetchManagedUsage, formatDuration, formatResetTime, - isManagedPythinkerCode, + isManagedKimiCode, parseManagedUsagePayload, } from '../src/managed-usage'; @@ -12,14 +12,14 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe('isManagedPythinkerCode', () => { +describe('isManagedKimiCode', () => { it('matches only the pythinker-code managed provider', () => { - expect(isManagedPythinkerCode('managed:pythinker-code')).toBe(true); - expect(isManagedPythinkerCode('managed:pythoughts-labs')).toBe(false); - expect(isManagedPythinkerCode('openai')).toBe(false); - expect(isManagedPythinkerCode('')).toBe(false); - expect(isManagedPythinkerCode(null)).toBe(false); - expect(isManagedPythinkerCode()).toBe(false); + expect(isManagedKimiCode('managed:kimi-code')).toBe(true); + expect(isManagedKimiCode('managed:pythoughts-labs')).toBe(false); + expect(isManagedKimiCode('openai')).toBe(false); + expect(isManagedKimiCode('')).toBe(false); + expect(isManagedKimiCode(null)).toBe(false); + expect(isManagedKimiCode()).toBe(false); }); }); @@ -152,7 +152,7 @@ describe('fetchManagedUsage', () => { expect(result.kind).toBe('error'); if (result.kind !== 'error') return; expect(result.status).toBe(404); - expect(result.message).toBe('Usage endpoint not available. Try Pythinker For Coding.'); + expect(result.message).toBe('Usage endpoint not available. Try Kimi For Coding.'); }); }); diff --git a/packages/oauth/test/open-platform.test.ts b/packages/oauth/test/open-platform.test.ts index 14989d25..395b97e6 100644 --- a/packages/oauth/test/open-platform.test.ts +++ b/packages/oauth/test/open-platform.test.ts @@ -10,7 +10,7 @@ import { OPEN_PLATFORMS, OpenPlatformApiError, removeOpenPlatformConfig, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '../src/open-platform'; function makeModelsResponse(): Response { @@ -155,7 +155,7 @@ describe('filterModelsByPrefix', () => { { id: 'gpt-4', contextLength: 1000, supportsReasoning: false, supportsImageIn: false, supportsVideoIn: false }, ]; - const filtered = filterModelsByPrefix(models as unknown as import('../src/managed-pythinker-code').ManagedPythinkerCodeModelInfo[], platform); + const filtered = filterModelsByPrefix(models as unknown as import('../src/managed-kimi-code').ManagedKimiCodeModelInfo[], platform); expect(filtered).toHaveLength(1); expect(filtered[0]?.id).toBe('kimi-k2-0712-preview'); }); @@ -171,7 +171,7 @@ describe('filterModelsByPrefix', () => { { id: 'model-b', contextLength: 2000, supportsReasoning: false, supportsImageIn: false, supportsVideoIn: false }, ]; - const filtered = filterModelsByPrefix(models as unknown as import('../src/managed-pythinker-code').ManagedPythinkerCodeModelInfo[], platform); + const filtered = filterModelsByPrefix(models as unknown as import('../src/managed-kimi-code').ManagedKimiCodeModelInfo[], platform); expect(filtered).toHaveLength(2); }); }); @@ -275,7 +275,7 @@ describe('capabilitiesForModel', () => { describe('applyOpenPlatformConfig', () => { it('writes provider, models, and defaults', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: {}, }; const platform = getOpenPlatformById('moonshot-cn')!; @@ -315,7 +315,7 @@ describe('applyOpenPlatformConfig', () => { }); it('clears stale models for the same provider', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { 'moonshot-cn': { type: 'pythinker', baseUrl: 'https://api.moonshot.cn/v1', apiKey: 'sk-old' }, }, @@ -344,7 +344,7 @@ describe('applyOpenPlatformConfig', () => { describe('removeOpenPlatformConfig', () => { it('removes provider, its models, and defaultModel when matched', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { 'moonshot-cn': { type: 'pythinker', baseUrl: 'https://api.moonshot.cn/v1', apiKey: 'sk-test' }, 'other': { type: 'pythinker', baseUrl: 'https://other.test/v1', apiKey: 'sk-other' }, @@ -366,7 +366,7 @@ describe('removeOpenPlatformConfig', () => { }); it('leaves defaultModel intact when it belongs to another provider', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { 'moonshot-cn': { type: 'pythinker', baseUrl: 'https://api.moonshot.cn/v1', apiKey: 'sk-test' }, }, diff --git a/packages/oauth/test/openai-codex-oauth.test.ts b/packages/oauth/test/openai-codex-oauth.test.ts index 161bcc62..b736ec58 100644 --- a/packages/oauth/test/openai-codex-oauth.test.ts +++ b/packages/oauth/test/openai-codex-oauth.test.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { describe, expect, it, vi } from 'vitest'; -import type { ManagedPythinkerConfigShape } from '../src/managed-pythinker-code'; +import type { ManagedKimiConfigShape } from '../src/managed-kimi-code'; import { applyOpenAICodexOAuthConfig, buildOpenAICodexAuthorizeUrl, @@ -200,7 +200,7 @@ describe('openai-codex-oauth', () => { }); const config = { providers: {}, - } as ManagedPythinkerConfigShape; + } as ManagedKimiConfigShape; const models = CODEX_MODELS_RESPONSE.models.map((model) => ({ id: model.slug, contextLength: model.context_window, @@ -245,7 +245,7 @@ describe('openai-codex-oauth', () => { }); it('uses the selected model highest supported effort when max is unavailable', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: {}, thinking: { mode: 'auto', effort: 'low' }, }; diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 27df6b15..e988e88e 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -3,12 +3,12 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { - applyManagedPythinkerCodeConfig, - PYTHINKER_CODE_PROVIDER_NAME, + applyManagedKimiCodeConfig, + KIMI_CODE_PROVIDER_NAME, PythinkerOAuthToolkit, - resolvePythinkerCodeOAuthKey, + resolveKimiCodeOAuthKey, resolvePythinkerTokenStorageName, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, type TokenInfo, type TokenStorage, } from '../src'; @@ -80,18 +80,18 @@ describe('resolvePythinkerTokenStorageName', () => { it('maps config oauth keys to the file storage token name', () => { expect( resolvePythinkerTokenStorageName({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, - oauthKey: 'oauth/pythinker-code', + providerName: KIMI_CODE_PROVIDER_NAME, + oauthKey: 'oauth/kimi-code', }), - ).toBe('pythinker-code'); - expect(resolvePythinkerTokenStorageName({ oauthKey: 'pythinker-code' })).toBe('pythinker-code'); + ).toBe('kimi-code'); + expect(resolvePythinkerTokenStorageName({ oauthKey: 'kimi-code' })).toBe('kimi-code'); }); it('rejects unsupported providers and unsafe token keys', () => { expect(() => resolvePythinkerTokenStorageName({ providerName: 'custom', - oauthKey: 'pythinker-code', + oauthKey: 'kimi-code', }), ).toThrow(/No OAuth manager/); expect(() => resolvePythinkerTokenStorageName({ oauthKey: '../pythinker-code' })).toThrow(/Invalid/); @@ -101,7 +101,7 @@ describe('resolvePythinkerTokenStorageName', () => { describe('PythinkerOAuthToolkit', () => { it('can be constructed without host identity', async () => { const storage = new MemoryTokenStorage(); - storage.tokens.set('pythinker-code', token('access-1')); + storage.tokens.set('kimi-code', token('access-1')); const toolkit = new PythinkerOAuthToolkit({ homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), storage, @@ -113,7 +113,7 @@ describe('PythinkerOAuthToolkit', () => { it('reports status and exposes a bearer token provider', async () => { const storage = new MemoryTokenStorage(); - storage.tokens.set('pythinker-code', token('access-1')); + storage.tokens.set('kimi-code', token('access-1')); const toolkit = new PythinkerOAuthToolkit({ homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), identity: TEST_IDENTITY, @@ -122,7 +122,7 @@ describe('PythinkerOAuthToolkit', () => { }); await expect(toolkit.status()).resolves.toEqual({ - providers: [{ providerName: PYTHINKER_CODE_PROVIDER_NAME, hasToken: true }], + providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], }); await expect(toolkit.tokenProvider().getAccessToken()).resolves.toBe('access-1'); }); @@ -139,7 +139,7 @@ describe('PythinkerOAuthToolkit', () => { await expect( toolkit - .tokenProvider(PYTHINKER_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code' }) + .tokenProvider(KIMI_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code' }) .getAccessToken(), ).resolves.toBe('custom-access'); }); @@ -147,7 +147,7 @@ describe('PythinkerOAuthToolkit', () => { it('refreshes configured bearer token refs against their OAuth host', async () => { const storage = new MemoryTokenStorage(); const oauthHost = 'https://auth.dev.example.test'; - const oauthKey = resolvePythinkerCodeOAuthKey({ + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost, baseUrl: 'https://api.dev.example.test/coding/v1', }); @@ -177,7 +177,7 @@ describe('PythinkerOAuthToolkit', () => { storage, now: () => 1_000, flowConfig: { - name: 'pythinker-code', + name: 'kimi-code', oauthHost: 'https://auth.kimi.com', clientId: 'test-client-id', }, @@ -185,7 +185,7 @@ describe('PythinkerOAuthToolkit', () => { await expect( toolkit - .tokenProvider(PYTHINKER_CODE_PROVIDER_NAME, { key: oauthKey, oauthHost }) + .tokenProvider(KIMI_CODE_PROVIDER_NAME, { key: oauthKey, oauthHost }) .getAccessToken(), ).resolves.toBe('rotated-dev-access'); }); @@ -217,7 +217,7 @@ describe('PythinkerOAuthToolkit', () => { storage, now: () => 1_000, flowConfig: { - name: 'pythinker-code', + name: 'kimi-code', oauthHost: 'https://auth.kimi.com', clientId: 'test-client-id', }, @@ -225,7 +225,7 @@ describe('PythinkerOAuthToolkit', () => { await expect( toolkit - .tokenProvider(PYTHINKER_CODE_PROVIDER_NAME, { + .tokenProvider(KIMI_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code', oauthHost: 'https://auth.one.test/', }) @@ -233,7 +233,7 @@ describe('PythinkerOAuthToolkit', () => { ).resolves.toBe('rotated-1'); await expect( toolkit - .tokenProvider(PYTHINKER_CODE_PROVIDER_NAME, { + .tokenProvider(KIMI_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code', oauthHost: 'https://auth.two.test', }) @@ -248,7 +248,7 @@ describe('PythinkerOAuthToolkit', () => { it('returns the cached access token without refreshing it', async () => { const storage = new MemoryTokenStorage(); - storage.tokens.set('pythinker-code', { + storage.tokens.set('kimi-code', { ...token('cached-access'), expiresAt: 1, }); @@ -273,7 +273,7 @@ describe('PythinkerOAuthToolkit', () => { }); await expect( - toolkit.getCachedAccessToken(PYTHINKER_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code' }), + toolkit.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code' }), ).resolves.toBe('custom-cached-access'); }); @@ -303,7 +303,7 @@ describe('PythinkerOAuthToolkit', () => { read: () => config, write, apply: (target, input) => { - target.providers[PYTHINKER_CODE_PROVIDER_NAME] = { + target.providers[KIMI_CODE_PROVIDER_NAME] = { type: 'pythinker', apiKey: '', }; @@ -315,9 +315,9 @@ describe('PythinkerOAuthToolkit', () => { }, }); - storage.tokens.set('pythinker-code', token('access-1')); + storage.tokens.set('kimi-code', token('access-1')); await expect(toolkit.login()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, provision: { defaultModel: 'pythinker-code/pythinker-for-coding', @@ -334,7 +334,7 @@ describe('PythinkerOAuthToolkit', () => { const onDeviceCode = vi.fn(); const config = { providers: {} }; const oauthHost = 'https://auth.test'; - const oauthKey = resolvePythinkerCodeOAuthKey({ oauthHost }); + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost }); storage.tokens.set(resolvePythinkerTokenStorageName({ oauthKey }), token('stale-access')); const fetchMock = vi .fn() @@ -373,7 +373,7 @@ describe('PythinkerOAuthToolkit', () => { now: () => 100, fetchImpl, flowConfig: { - name: 'pythinker-code', + name: 'kimi-code', oauthHost, clientId: 'test-client-id', }, @@ -381,7 +381,7 @@ describe('PythinkerOAuthToolkit', () => { read: () => config, write, apply: (target, input) => { - target.providers[PYTHINKER_CODE_PROVIDER_NAME] = { + target.providers[KIMI_CODE_PROVIDER_NAME] = { type: 'pythinker', apiKey: '', }; @@ -394,7 +394,7 @@ describe('PythinkerOAuthToolkit', () => { }); await expect(toolkit.login(undefined, { onDeviceCode })).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, provision: { defaultModel: 'pythinker-code/pythinker-for-coding', @@ -417,11 +417,11 @@ describe('PythinkerOAuthToolkit', () => { it('uses a scoped credential slot for non-default OAuth login environments', async () => { const storage = new MemoryTokenStorage(); - storage.tokens.set('pythinker-code', token('prod-access')); - const config: ManagedPythinkerConfigShape = { providers: {} }; + storage.tokens.set('kimi-code', token('prod-access')); + const config: ManagedKimiConfigShape = { providers: {} }; const devBaseUrl = 'https://api.dev.example.test/coding/v1'; const devOauthHost = 'https://auth.dev.example.test'; - const devOauthKey = resolvePythinkerCodeOAuthKey({ + const devOauthKey = resolveKimiCodeOAuthKey({ oauthHost: devOauthHost, baseUrl: devBaseUrl, }); @@ -465,25 +465,25 @@ describe('PythinkerOAuthToolkit', () => { now: () => 100, fetchImpl: fetchMock as unknown as typeof fetch, flowConfig: { - name: 'pythinker-code', + name: 'kimi-code', oauthHost: devOauthHost, clientId: 'test-client-id', }, configAdapter: { read: () => config, write, - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); await expect(toolkit.login(undefined, { baseUrl: devBaseUrl })).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, }); expect(oauthFetch).toHaveBeenCalledTimes(2); - expect(storage.tokens.get('pythinker-code')?.accessToken).toBe('prod-access'); + expect(storage.tokens.get('kimi-code')?.accessToken).toBe('prod-access'); expect(storage.tokens.get(devStorageName)?.accessToken).toBe('dev-access'); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]?.oauth).toEqual({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]?.oauth).toEqual({ storage: 'file', key: devOauthKey, oauthHost: devOauthHost, @@ -496,7 +496,7 @@ describe('PythinkerOAuthToolkit', () => { it('starts a new device flow when the stored refresh token is invalid', async () => { const storage = new MemoryTokenStorage(); const oauthHost = 'https://auth.test'; - const oauthKey = resolvePythinkerCodeOAuthKey({ oauthHost }); + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost }); const storageName = resolvePythinkerTokenStorageName({ oauthKey }); storage.tokens.set(storageName, { ...token('stale-access'), @@ -547,14 +547,14 @@ describe('PythinkerOAuthToolkit', () => { storage, now: () => 100, flowConfig: { - name: 'pythinker-code', + name: 'kimi-code', oauthHost, clientId: 'test-client-id', }, }); await expect(toolkit.login(undefined, { onDeviceCode })).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, }); expect(onDeviceCode).toHaveBeenCalledTimes(1); @@ -563,8 +563,8 @@ describe('PythinkerOAuthToolkit', () => { it('removes managed config on logout when an adapter supports cleanup', async () => { const storage = new MemoryTokenStorage(); - storage.tokens.set('pythinker-code', token('access-1')); - const config = { providers: { [PYTHINKER_CODE_PROVIDER_NAME]: { type: 'pythinker' } } }; + storage.tokens.set('kimi-code', token('access-1')); + const config = { providers: { [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker' } } }; const write = vi.fn(); const remove = vi.fn(); const toolkit = new PythinkerOAuthToolkit({ @@ -581,11 +581,11 @@ describe('PythinkerOAuthToolkit', () => { }); await expect(toolkit.logout()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, }); expect(remove).toHaveBeenCalledWith(config); expect(write).toHaveBeenCalledWith(config); - await expect(storage.load('pythinker-code')).resolves.toBeUndefined(); + await expect(storage.load('kimi-code')).resolves.toBeUndefined(); }); }); diff --git a/packages/server/test/auth.e2e.test.ts b/packages/server/test/auth.e2e.test.ts index c24b71df..258606f0 100644 --- a/packages/server/test/auth.e2e.test.ts +++ b/packages/server/test/auth.e2e.test.ts @@ -186,16 +186,16 @@ describe('GET /api/v1/auth — readiness probe (P2.1 D2)', () => { }); }); - it('surfaces managed_provider.unauthenticated when config has managed:pythinker-code but no cached token', async () => { + it('surfaces managed_provider.unauthenticated when config has managed:kimi-code but no cached token', async () => { seedConfig( [ - '[providers."managed:pythinker-code"]', + '[providers."managed:kimi-code"]', 'type = "pythinker"', 'base_url = "https://example/v1"', '', - '[providers."managed:pythinker-code".oauth]', + '[providers."managed:kimi-code".oauth]', 'storage = "file"', - 'key = "oauth/pythinker-code"', + 'key = "oauth/kimi-code"', '', ].join('\n'), ); @@ -204,7 +204,7 @@ describe('GET /api/v1/auth — readiness probe (P2.1 D2)', () => { const env = envelopeOf(res.json()); const summary = authSummarySchema.parse(env.data); expect(summary.managed_provider).toEqual({ - name: 'managed:pythinker-code', + name: 'managed:kimi-code', status: 'unauthenticated', }); // ready is still false — no default_model, even though provider exists diff --git a/packages/server/test/model-catalog.e2e.test.ts b/packages/server/test/model-catalog.e2e.test.ts index 920aaefc..a4e9feb3 100644 --- a/packages/server/test/model-catalog.e2e.test.ts +++ b/packages/server/test/model-catalog.e2e.test.ts @@ -242,7 +242,7 @@ describe('model/provider catalog routes', () => { refreshOAuthProviderModels: async () => ({ changed: [ { - provider_id: 'managed:pythinker-code', + provider_id: 'managed:kimi-code', provider_name: 'Pythinker Code', added: 1, removed: 0, @@ -266,7 +266,7 @@ describe('model/provider catalog routes', () => { expect(env.data).toEqual({ changed: [ { - provider_id: 'managed:pythinker-code', + provider_id: 'managed:kimi-code', provider_name: 'Pythinker Code', added: 1, removed: 0, diff --git a/packages/server/test/oauth.e2e.test.ts b/packages/server/test/oauth.e2e.test.ts index 4437bc93..236f0346 100644 --- a/packages/server/test/oauth.e2e.test.ts +++ b/packages/server/test/oauth.e2e.test.ts @@ -91,7 +91,7 @@ function makeStub(scripted: { }; const defaultStart: OAuthFlowStart = { flow_id: 'oauth_01ABCDEFGH', - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', verification_uri: 'https://example.com/device', verification_uri_complete: 'https://example.com/device?user_code=PYTH-1234', user_code: 'PYTH-1234', @@ -116,7 +116,7 @@ function makeStub(scripted: { }, logout: async (provider) => { calls.logout.push({ provider }); - return scripted.logout ?? { logged_out: true, provider: 'managed:pythinker-code' }; + return scripted.logout ?? { logged_out: true, provider: 'managed:kimi-code' }; }, }; } @@ -221,7 +221,7 @@ describe('GET /api/v1/oauth/login (P2.7)', () => { it('returns the snapshot when present', async () => { const snap: OAuthFlowSnapshot = { flow_id: 'oauth_01ABCDEFGH', - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', status: 'pending', verification_uri: 'https://example.com/device', verification_uri_complete: 'https://example.com/device?user_code=PYTH-1234', @@ -247,7 +247,7 @@ describe('GET /api/v1/oauth/login (P2.7)', () => { it('reflects terminal-state snapshots', async () => { const snap: OAuthFlowSnapshot = { flow_id: 'oauth_01ABCDEFGH', - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', status: 'authenticated', verification_uri: 'https://example.com/device', verification_uri_complete: 'https://example.com/device?user_code=PYTH-1234', @@ -312,7 +312,7 @@ describe('POST /api/v1/oauth/logout (P2.7)', () => { expect(env.code).toBe(0); expect(env.data).toEqual({ logged_out: true, - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', }); expect(stub.calls.logout).toHaveLength(1); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fcc174e..5f9460ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,10 @@ overrides: importers: .: + dependencies: + '@earendil-works/pi-tui': + specifier: ^0.83.0 + version: 0.83.0 devDependencies: '@arethetypeswrong/cli': specifier: 0.18.3 @@ -165,8 +169,8 @@ importers: version: 0.25.10 devDependencies: '@earendil-works/pi-tui': - specifier: ^0.81.1 - version: 0.81.1 + specifier: ^0.83.0 + version: 0.83.0 '@pythoughts/acp-adapter': specifier: workspace:^ version: link:../../packages/acp-adapter @@ -1320,8 +1324,8 @@ packages: '@dotenvx/primitives@0.8.0': resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==} - '@earendil-works/pi-tui@0.81.1': - resolution: {integrity: sha512-OMEe+Zt8oQYi/rCq3upxsTlIScWL0FPhXwQus34TbQb3EmTx88S7Uzx32JxvQiEeWOw8eDCdJf2PBUBE9r6wIg==} + '@earendil-works/pi-tui@0.83.0': + resolution: {integrity: sha512-IoYrb0rORjELmEpNtoCA/U8je3KopMkRAVJRdSzvXRvgb+Huo1gNh8Q5CSZvNOiYtDxJdj2tYZZHZ4B3+IN3hA==} engines: {node: '>=22.19.0'} '@emnapi/core@1.10.0': @@ -10446,7 +10450,7 @@ snapshots: '@dotenvx/primitives@0.8.0': {} - '@earendil-works/pi-tui@0.81.1': + '@earendil-works/pi-tui@0.83.0': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5