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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
415 changes: 376 additions & 39 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts

Large diffs are not rendered by default.

85 changes: 68 additions & 17 deletions packages/cli/src/__tests__/runtime-host-onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy';
import { projectProviders, projectRuntimeHostModelChoices } from '../runtime-host-onboarding.js';
import type { RuntimeHostConnection } from '@maka/runtime-host/client';
import {
createRuntimeHostOnboardingSurface,
projectProviders,
projectRuntimeHostModelChoices,
} from '../runtime-host-onboarding.js';

function catalog(connections: ConnectionCatalogSnapshot['connections']): ConnectionCatalogSnapshot {
return { revision: 1, defaultTarget: null, connections };
Expand All @@ -37,6 +42,44 @@ const live = {
models: [{ id: 'gpt-5-mini', displayName: 'GPT-5 Mini' }],
} as const;

describe('createRuntimeHostOnboardingSurface', () => {
test('keeps the committed Connection when the follow-up catalog refresh fails', async () => {
const committed = {
connectionId: 'committed-openai-id',
revision: 3,
slug: 'openai-2',
providerType: 'openai',
} as const;
const connection = {
request: async (operation: string) => {
if (operation === 'connection.onboarding.save') {
return { kind: 'saved', connection: committed };
}
if (operation === 'connection.catalog.query') {
throw new Error('transient catalog failure');
}
throw new Error(`Unexpected operation ${operation}`);
},
} as unknown as RuntimeHostConnection;

const result = await createRuntimeHostOnboardingSurface(connection).save({
target: { kind: 'create', providerType: 'openai' },
apiKey: 'sk-test',
enabledModelIds: ['gpt-5-mini'],
models: [{ id: 'gpt-5-mini' }],
});

assert.deepEqual(result, {
kind: 'ok',
connection: committed,
refresh: {
kind: 'failed',
warning: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。',
},
});
});
});

describe('projectRuntimeHostModelChoices', () => {
test('a retained retired connection contributes no /model choices', () => {
// Retirement keeps the connection enabled so its credential stays visible
Expand Down Expand Up @@ -92,31 +135,39 @@ describe('projectProviders', () => {
models: [{ id: 'relay/model' }],
} as const;

test('a Desktop-created relay under a custom slug reads as the existing connection', () => {
// Identity must survive the projection: a sole connection of the provider
// type is "the" one to edit even off the canonical slug, or saving would
// duplicate it there (#3467 review).
const entry = projectProviders(catalog([relay])).find(
test('a Desktop-created relay and add-account action are both explicit', () => {
const entries = projectProviders(catalog([relay])).filter(
({ providerType }) => providerType === 'openai-compatible',
);
assert.equal(entry?.hasConnection, true);
assert.equal(entry?.connectionId, 'relay-custom-id');
const entry = entries.find(({ target }) => target.kind === 'existing');
assert.deepEqual(entry?.target, { kind: 'existing', connectionId: 'relay-custom-id' });
assert.equal(entry && 'connectionSlug' in entry ? entry.connectionSlug : undefined, 'my-relay');
assert.deepEqual(entry?.enabledModelIds, ['relay/model']);
assert.deepEqual(entries.find(({ target }) => target.kind === 'create')?.target, {
kind: 'create',
providerType: 'openai-compatible',
});
});

test('several non-canonical connections resolve to none — the wizard offers a fresh setup', () => {
const entry = projectProviders(
test('several non-canonical connections remain independently editable', () => {
const entries = projectProviders(
catalog([relay, { ...relay, connectionId: 'relay-2-id', slug: 'my-relay-2' }]),
).find(({ providerType }) => providerType === 'openai-compatible');
assert.equal(entry?.hasConnection, false);
assert.equal(entry?.connectionId, undefined);
).filter(({ providerType }) => providerType === 'openai-compatible');
assert.deepEqual(
entries.flatMap(({ target }) => (target.kind === 'existing' ? [target.connectionId] : [])),
['relay-custom-id', 'relay-2-id'],
);
});

test('the canonical-slug connection wins over other connections of the type', () => {
test('a canonical connection does not hide another account', () => {
const canonical = { ...relay, connectionId: 'canonical-id', slug: 'openai-compatible' };
const entry = projectProviders(catalog([relay, canonical])).find(
({ providerType }) => providerType === 'openai-compatible',
const entries = projectProviders(catalog([relay, canonical])).filter(
({ providerType, target }) =>
providerType === 'openai-compatible' && target.kind === 'existing',
);
assert.deepEqual(
entries.flatMap(({ target }) => (target.kind === 'existing' ? [target.connectionId] : [])),
['relay-custom-id', 'canonical-id'],
);
assert.equal(entry?.connectionId, 'canonical-id');
});
});
40 changes: 27 additions & 13 deletions packages/cli/src/pi-tui-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import type { ForeignSessionDigest, ForeignSessionSummary } from '@maka/core/foreign-session';
import type { ModelInfo, ProviderType } from '@maka/core/llm-connections';
import type { ThinkingLevel } from '@maka/core/model-thinking';
import type { ConnectionOnboardingTarget } from '@maka/core/runtime-policy';
import type { MakaPiTuiTurnActivity } from './pi-tui-turn.js';

export interface ModelChoice {
Expand Down Expand Up @@ -52,17 +53,21 @@ export interface OnboardableProvider {
fallbackModels: readonly string[];
}

export interface OnboardingProviderEntry extends OnboardableProvider {
hasConnection: boolean;
/** The existing connection's identity, so saving edits it in place. */
connectionId?: string;
enabledModelIds: readonly string[];
}
export type OnboardingProviderEntry = OnboardableProvider &
(
| {
target: Extract<ConnectionOnboardingTarget, { readonly kind: 'create' }>;
enabledModelIds: readonly string[];
}
| {
target: Extract<ConnectionOnboardingTarget, { readonly kind: 'existing' }>;
connectionSlug: string;
enabledModelIds: readonly string[];
}
);

export interface OnboardingVerifyInput {
providerType: ProviderType;
/** The existing connection this edit targets; absent creates/updates the canonical-slug one. */
connectionId?: string;
target: ConnectionOnboardingTarget;
apiKey?: string;
/** Endpoint for `requiresBaseUrl` providers; blank reuses the persisted one. */
baseUrl?: string;
Expand All @@ -80,18 +85,27 @@ export type OnboardingVerifyResult =
};

export interface OnboardingSaveInput {
providerType: ProviderType;
/** The existing connection this edit targets; absent creates/updates the canonical-slug one. */
connectionId?: string;
target: ConnectionOnboardingTarget;
apiKey?: string;
/** Endpoint for `requiresBaseUrl` providers; blank reuses the persisted one. */
baseUrl?: string;
enabledModelIds: readonly string[];
models: readonly ModelInfo[];
}

export interface OnboardingSavedConnection {
connectionId: string;
revision: number;
slug: string;
providerType: ProviderType;
}

export type OnboardingSaveResult =
| { kind: 'ok'; modelChoices: ModelChoice[] }
| {
kind: 'ok';
connection: OnboardingSavedConnection;
refresh: { kind: 'ok'; modelChoices: ModelChoice[] } | { kind: 'failed'; warning: string };
}
| { kind: 'error'; text: string };

export interface MakaOnboardingSurface {
Expand Down
87 changes: 71 additions & 16 deletions packages/cli/src/pi-tui-pickers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,16 +591,57 @@ export function modelPickerItems(
* caller maps it back to the {@link ModelChoice}. The description carries the
* owning connection so identical model ids on different providers are readable.
*/
export function modelChoiceConnectionLabels(choices: readonly ModelChoice[]): Map<string, string> {
const connectionBySlug = new Map<
string,
Pick<ModelChoice, 'connectionId' | 'connectionName' | 'connectionSlug'>
>();
for (const choice of choices) {
if (!connectionBySlug.has(choice.connectionSlug)) {
connectionBySlug.set(choice.connectionSlug, choice);
}
}
const connections = [...connectionBySlug.values()]
.map((choice) => ({
...choice,
base: choice.connectionName.trim()
? `${choice.connectionName.trim()} · ${choice.connectionSlug}`
: choice.connectionSlug,
}))
.sort(
(left, right) =>
left.base.localeCompare(right.base) ||
left.connectionSlug.localeCompare(right.connectionSlug),
);
const used = new Set<string>();
const labels = new Map<string, string>();
for (const connection of connections) {
let label = connection.base;
if (used.has(label) && connection.connectionId) {
label = `${connection.base} · ${connection.connectionId}`;
}
let suffix = 2;
while (used.has(label)) {
label = `${connection.base} · ${connection.connectionId ?? connection.connectionSlug} · ${suffix}`;
suffix += 1;
}
used.add(label);
labels.set(connection.connectionSlug, label);
}
return labels;
}

function modelChoicePickerItems(
choices: readonly ModelChoice[],
current: { model: string; connectionId?: string; connectionSlug: string },
): SelectItem[] {
const connectionLabels = modelChoiceConnectionLabels(choices);
return choices.map((choice, index) => {
const isCurrent =
choice.model === current.model &&
choice.connectionId === current.connectionId &&
choice.connectionSlug === current.connectionSlug;
const tags = [choice.connectionName || choice.connectionSlug];
const tags = [connectionLabels.get(choice.connectionSlug) ?? choice.connectionSlug];
if (isCurrent) tags.push('current');
else if (choice.isDefaultConnection) tags.push('default');
return {
Expand Down Expand Up @@ -797,14 +838,21 @@ export function onboardingProviderPickerItems(
providers: readonly OnboardingProviderEntry[],
): SelectItem[] {
return providers.map((provider) => ({
value: provider.providerType,
value: onboardingProviderKey(provider),
label: provider.label,
description: provider.hasConnection
? `${provider.providerType} · 已设置`
: provider.providerType,
description:
'connectionSlug' in provider
? `${provider.providerType} · ${provider.connectionSlug} · 已设置`
: `${provider.providerType} · 添加账号`,
}));
}

function onboardingProviderKey(provider: OnboardingProviderEntry): string {
return provider.target.kind === 'existing'
? provider.target.connectionId
: `create:${provider.target.providerType}`;
}

const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
off: '关',
minimal: '最小',
Expand Down Expand Up @@ -857,7 +905,7 @@ export interface OnboardingWizardInput {
/** search→key: the user picked a provider. The runner records it — and the
* existing connection's identity, when the catalog resolved one — for
* verify/save, so saving edits that connection in place. */
onPickProvider: (providerType: ProviderType, existingConnectionId: string | undefined) => void;
onPickProvider: (provider: OnboardingProviderEntry) => void;
/** baseUrl submit (only for `requiresBaseUrl` providers). Empty means "reuse
* the existing connection's persisted endpoint"; the wizard has already
* rejected an empty value for a provider with no connection. */
Expand Down Expand Up @@ -905,6 +953,7 @@ export class OnboardingWizard implements Component {
private modelHighlight = 0;
private modelScroll = 0;
private successCount = 0;
private successWarning: string | undefined;

constructor(
private readonly tui: TUI,
Expand Down Expand Up @@ -943,7 +992,9 @@ export class OnboardingWizard implements Component {
{ minPrimaryColumnWidth: 16, maxPrimaryColumnWidth: 32 },
);
list.onSelect = (item) => {
const provider = this.filtered.find((p) => p.providerType === item.value);
const provider = this.filtered.find(
(candidate) => onboardingProviderKey(candidate) === item.value,
);
if (!provider) return;
this.enterKeyPhase(provider);
};
Expand All @@ -968,7 +1019,7 @@ export class OnboardingWizard implements Component {
this.modelHighlight = 0;
this.modelScroll = 0;
this.modelsSearchEditor.setText('');
this.input.onPickProvider(provider.providerType, provider.connectionId);
this.input.onPickProvider(provider);
}

private submitBaseUrl(value: string): void {
Expand All @@ -991,7 +1042,7 @@ export class OnboardingWizard implements Component {
*/
private validateBaseUrl(trimmed: string): string | null {
if (!trimmed) {
return this.picked?.hasConnection ? null : '需要填写 Base URL';
return this.picked?.target.kind === 'existing' ? null : '需要填写 Base URL';
}
let parsed: URL;
try {
Expand Down Expand Up @@ -1086,9 +1137,10 @@ export class OnboardingWizard implements Component {
}

/** Runner hook: save succeeded — show the enabled-model count in-frame. */
setSuccess(enabledCount: number): void {
setSuccess(enabledCount: number, warning?: string): void {
this.phase = 'success';
this.successCount = enabledCount;
this.successWarning = warning;
this.status = { kind: 'prompt' };
}

Expand Down Expand Up @@ -1286,9 +1338,10 @@ export class OnboardingWizard implements Component {
this.keyEditor.focused = false;
this.modelsSearchEditor.focused = false;
const label = this.picked?.label ?? '';
const hint = this.picked?.hasConnection
? '留空复用已保存的 Base URL,或输入新地址替换 · Esc 返回选择服务商'
: '输入中转站的 Base URL(http/https)· Esc 返回选择服务商';
const hint =
this.picked?.target.kind === 'existing'
? '留空复用已保存的 Base URL,或输入新地址替换 · Esc 返回选择服务商'
: '输入中转站的 Base URL(http/https)· Esc 返回选择服务商';
return [
padLine(`Set Up Provider ${ansi.dim(`· ${this.step(2)}`)} ${ansi.accent(label)}`, width),
padLine(ansi.dim(hint), width),
Expand Down Expand Up @@ -1331,9 +1384,10 @@ export class OnboardingWizard implements Component {
this.modelsSearchEditor.focused = false;
const label = this.picked?.label ?? '';
const backTarget = this.picked?.requiresBaseUrl ? 'Esc 返回 Base URL' : 'Esc 返回选择服务商';
const hint = this.picked?.hasConnection
? `留空复用已保存的 key,或输入新 key 轮换 · ${backTarget}`
: `输入 API key · 仅本机存储 · ${backTarget}`;
const hint =
this.picked?.target.kind === 'existing'
? `留空复用已保存的 key,或输入新 key 轮换 · ${backTarget}`
: `输入 API key · 仅本机存储 · ${backTarget}`;
return [
padLine(
`Set Up Provider ${ansi.dim(`· ${this.step(this.picked?.requiresBaseUrl ? 3 : 2)}`)} ${ansi.accent(label)}`,
Expand Down Expand Up @@ -1420,6 +1474,7 @@ export class OnboardingWizard implements Component {
return [
padLine(`Set Up Provider ${ansi.dim('· 完成')} ${ansi.accent(label)}`, width),
padLine(ansi.green(`✓ 已启用 ${this.successCount} 个模型`), width),
...(this.successWarning ? [padLine(ansi.yellow(this.successWarning), width)] : []),
padLine('', width),
padLine(ansi.dim('Enter 关闭'), width),
padLine(ansi.accent('-'.repeat(width)), width),
Expand Down
Loading