fix: harden team launch bootstrap provisioning

This commit is contained in:
777genius 2026-05-19 19:42:40 +03:00
parent 85959b6954
commit bf3011624d
53 changed files with 1720 additions and 235 deletions

View file

@ -22,6 +22,13 @@ Default local run target:
- Do not start the browser/web dev mode for normal development or smoke checks. The browser path is limited and lacks the full desktop runtime, IPC, terminal, provider auth, and team lifecycle behavior.
- When documenting or recommending startup commands, point contributors to the desktop app unless a task explicitly asks for browser-mode internals.
Live team smoke runtime:
- Use the orchestrator source launcher by default for live/dev smoke loops: `/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source`
- The source launcher runs `src/entrypoints/cli.tsx` through Bun, so it reflects local orchestrator source edits immediately and cannot accidentally test stale `dist` output.
- Use the built wrapper only for release or production-like smoke checks. Build first in `/Users/belief/dev/projects/claude/agent_teams_orchestrator` with `bun run build`, then set `CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH=/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli`.
- Do not use `cli-dev` or `bun run build:dev` as proof for the production wrapper. `cli` reads `dist/local-cli/cli.js`; `cli-dev` reads `dist/local-cli-dev/cli.js`.
Fast local lint:
- Use `pnpm lint:fast:files -- <changed files>` for quick preflight on files you touched.

View file

@ -92,11 +92,11 @@ If you want the FRESHEST version, clone the repo and run it from the `dev` branc
- [Installation](#installation)
- [Table of contents](#table-of-contents)
- [What is this](#what-is-this)
- [Developer architecture docs](#developer-architecture-docs)
- [Comparison](#comparison)
- [Quick start](#quick-start)
- [FAQ](#faq)
- [Development](#development)
- [Developer architecture docs](#developer-architecture-docs)
- [Tech stack](#tech-stack)
- [Build for distribution](#build-for-distribution)
- [Scripts](#scripts)
@ -159,15 +159,6 @@ An orchestration layer for AI agent teams across Claude, Codex, and OpenCode.
</details>
## Developer architecture docs
For feature architecture and implementation guidance:
- Canonical standard - [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md)
- Repo working instructions - [CLAUDE.md](CLAUDE.md)
- Feature root guidance - [src/features/README.md](src/features/README.md)
- Reference implementation - `src/features/recent-projects`
## Comparison
| Feature | Agent Teams | Gastown | Paperclip | Cursor | Claude Code CLI |
@ -263,6 +254,15 @@ Yes. Run multiple teams in one project or across different projects, even simult
## Development
### Developer architecture docs
For feature architecture and implementation guidance:
- Canonical standard - [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md)
- Repo working instructions - [CLAUDE.md](CLAUDE.md)
- Feature root guidance - [src/features/README.md](src/features/README.md)
- Reference implementation - `src/features/recent-projects`
## Tech stack
Electron 40, React 19, TypeScript 5, Tailwind CSS 3, Zustand 4. Data from `~/.claude/` (session logs, todos, tasks). The desktop app works with local runtime/session state, while some runtime modes may also use provider or startup capability services when required.

View file

@ -81,6 +81,32 @@ Use this mode to inspect interactive CLI behavior, terminal prompts, and pane ou
as equivalent to the process backend for recovery semantics; persisted pane IDs can help discovery,
but app restart does not make old panes a fully app-owned runtime again.
## Live Smoke Runtime Launcher
Live/dev smoke checks should run the orchestrator from source unless the test explicitly says it is
validating packaged output. This keeps app smoke tests aligned with the source tree and avoids a stale
`dist` bundle hiding runtime changes.
Default live/dev smoke launcher:
```bash
export CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH=/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source
```
The source launcher executes `src/entrypoints/cli.tsx` through Bun. It is the right default for local
debug loops, live model/provider checks, and cross-repo runtime fixes.
Release or production-like smoke checks must validate the built wrapper:
```bash
cd /Users/belief/dev/projects/claude/agent_teams_orchestrator
bun run build
export CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH=/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli
```
`cli` reads `dist/local-cli/cli.js`. `cli-dev` reads `dist/local-cli-dev/cli.js`, so a passing
`cli-dev` smoke is not proof that the production wrapper is fresh.
## Member State Meanings
Common `launch-state.json` cases:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

View file

@ -256,6 +256,10 @@
background: var(--cyber-hero-bg);
}
#hero.cyber-hero {
padding-top: 120px;
}
.cyber-hero__background,
.cyber-hero__monterey,
.cyber-hero__wash,

View file

@ -61,7 +61,7 @@ const docsHref = computed(() => {
.app-footer__robot-stage {
position: absolute;
right: clamp(24px, 7vw, 112px);
bottom: calc(100% - 18px);
bottom: calc(100% - 11px);
z-index: 2;
width: clamp(178px, 16vw, 236px);
pointer-events: none;

View file

@ -11,7 +11,6 @@
<style scoped>
.app-layout__main {
flex: 1;
padding-top: 64px;
background:
radial-gradient(circle at 78% 18%, rgba(0, 234, 255, 0.08), transparent 32%),
radial-gradient(circle at 18% 72%, rgba(47, 125, 255, 0.07), transparent 38%),

View file

@ -44,6 +44,12 @@ export interface AnthropicRuntimeReconciliation {
fastModeResetReason: string | null;
}
export type AnthropicEffortSupportResolution =
| { kind: 'supported'; source: 'catalog' | 'runtime-capability' | 'static-fallback' }
| { kind: 'unsupported-by-catalog'; supportedEfforts: EffortLevel[] }
| { kind: 'unsupported-by-runtime-capability'; supportedEfforts: EffortLevel[] }
| { kind: 'unverified-catalog-missing' };
function getAnthropicCatalog(
source: AnthropicRuntimeProfileSource
): CliProviderModelCatalog | null {
@ -77,17 +83,89 @@ function hasCatalogTruth(selection: AnthropicRuntimeSelection): boolean {
return selection.catalogSource !== 'unavailable' && selection.catalogStatus !== 'unavailable';
}
function stripOneMillionSuffix(model: string): string {
return model
.trim()
.toLowerCase()
.replace(/(?:\[1m\])+$/i, '');
}
function isKnownAnthropicReasoningModel(model: string | null | undefined): boolean {
const normalized = stripOneMillionSuffix(model ?? '');
return (
normalized === 'opus' ||
normalized === 'sonnet' ||
normalized === 'claude-opus-4-7' ||
normalized.startsWith('claude-opus-4-7-') ||
normalized === 'claude-opus-4-6' ||
normalized.startsWith('claude-opus-4-6-') ||
normalized === 'claude-sonnet-4-6' ||
normalized.startsWith('claude-sonnet-4-6-')
);
}
function normalizeRuntimeReasoningEfforts(
capabilities: CliProviderRuntimeCapabilities | null | undefined
): EffortLevel[] {
return normalizeEffortLevels(capabilities?.reasoningEffort?.values);
}
export function resolveAnthropicEffortSupport(params: {
selection: AnthropicRuntimeSelection;
effort: EffortLevel;
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
}): AnthropicEffortSupportResolution {
if (params.selection.catalogModel) {
return params.selection.supportedEfforts.includes(params.effort)
? { kind: 'supported', source: 'catalog' }
: { kind: 'unsupported-by-catalog', supportedEfforts: params.selection.supportedEfforts };
}
const runtimeReasoning = params.runtimeCapabilities?.reasoningEffort;
const runtimeEfforts = normalizeRuntimeReasoningEfforts(params.runtimeCapabilities);
if (runtimeReasoning) {
if (
runtimeReasoning.supported !== true ||
runtimeReasoning.configPassthrough !== true ||
!runtimeEfforts.includes(params.effort)
) {
return { kind: 'unsupported-by-runtime-capability', supportedEfforts: runtimeEfforts };
}
if (isKnownAnthropicReasoningModel(params.selection.resolvedLaunchModel)) {
return { kind: 'supported', source: 'runtime-capability' };
}
}
if (
!runtimeReasoning &&
isKnownAnthropicReasoningModel(params.selection.resolvedLaunchModel) &&
(params.effort === 'low' ||
params.effort === 'medium' ||
params.effort === 'high' ||
params.effort === 'max')
) {
return { kind: 'supported', source: 'static-fallback' };
}
return { kind: 'unverified-catalog-missing' };
}
export function resolveAnthropicRuntimeSelection(params: {
source: AnthropicRuntimeProfileSource;
selectedModel?: string | null;
limitContext: boolean;
availableLaunchModels?: Iterable<string>;
}): AnthropicRuntimeSelection {
const catalog = getAnthropicCatalog(params.source);
const availableLaunchModels =
catalog !== null
? catalog.models.map((model) => model.launchModel)
: params.availableLaunchModels;
const resolvedLaunchModel =
resolveAnthropicLaunchModel({
selectedModel: params.selectedModel,
limitContext: params.limitContext,
availableLaunchModels: catalog?.models.map((model) => model.launchModel),
availableLaunchModels,
defaultLaunchModel: catalog?.defaultLaunchModel ?? null,
}) ?? null;

View file

@ -1,4 +1,5 @@
export type {
AnthropicEffortSupportResolution,
AnthropicFastModeResolution,
AnthropicRuntimeProfileSource,
AnthropicRuntimeReconciliation,
@ -6,6 +7,7 @@ export type {
} from '../core/domain/resolveAnthropicRuntimeProfile';
export {
reconcileAnthropicRuntimeSelections,
resolveAnthropicEffortSupport,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '../core/domain/resolveAnthropicRuntimeProfile';

View file

@ -1,4 +1,5 @@
export type {
AnthropicEffortSupportResolution,
AnthropicFastModeResolution,
AnthropicRuntimeProfileSource,
AnthropicRuntimeReconciliation,
@ -6,6 +7,7 @@ export type {
} from '../core/domain/resolveAnthropicRuntimeProfile';
export {
reconcileAnthropicRuntimeSelections,
resolveAnthropicEffortSupport,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '../core/domain/resolveAnthropicRuntimeProfile';

View file

@ -222,6 +222,7 @@ import type {
TeamMessageNotificationData,
TeamProviderBackendId,
TeamProviderId,
TeamProvisioningModelCheckRequest,
TeamProvisioningModelVerificationMode,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
@ -2366,7 +2367,8 @@ async function handlePrepareProvisioning(
providerIds: unknown,
selectedModels: unknown,
limitContext: unknown,
modelVerificationMode: unknown
modelVerificationMode: unknown,
selectedModelChecks: unknown
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
let validatedCwd: string | undefined;
let validatedProviderId: TeamLaunchRequest['providerId'];
@ -2374,6 +2376,7 @@ async function handlePrepareProvisioning(
let validatedSelectedModels: string[] | undefined;
let validatedLimitContext: boolean | undefined;
let validatedModelVerificationMode: TeamProvisioningModelVerificationMode | undefined;
let validatedSelectedModelChecks: TeamProvisioningModelCheckRequest[] | undefined;
if (cwd !== undefined) {
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
return { success: false, error: 'cwd must be a non-empty string' };
@ -2436,6 +2439,51 @@ async function handlePrepareProvisioning(
}
validatedModelVerificationMode = modelVerificationMode;
}
if (selectedModelChecks !== undefined) {
if (!Array.isArray(selectedModelChecks)) {
return { success: false, error: 'selectedModelChecks must be an array when provided' };
}
const normalized: TeamProvisioningModelCheckRequest[] = [];
const seen = new Set<string>();
for (const entry of selectedModelChecks) {
if (!entry || typeof entry !== 'object') {
return { success: false, error: 'selectedModelChecks entries must be objects' };
}
const rawEntry = entry as {
providerId?: unknown;
model?: unknown;
effort?: unknown;
};
if (!isTeamProviderId(rawEntry.providerId)) {
return {
success: false,
error: 'selectedModelChecks entries must include a valid providerId',
};
}
if (typeof rawEntry.model !== 'string' || rawEntry.model.trim().length === 0) {
return {
success: false,
error: 'selectedModelChecks entries must include a non-empty model',
};
}
const effortValidation = parseOptionalTeamEffort(rawEntry.effort, rawEntry.providerId);
if (!effortValidation.valid) {
return { success: false, error: `selectedModelChecks ${effortValidation.error}` };
}
const model = rawEntry.model.trim();
const key = `${rawEntry.providerId}\n${model}\n${effortValidation.value ?? ''}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
normalized.push({
providerId: rawEntry.providerId,
model,
...(effortValidation.value ? { effort: effortValidation.value } : {}),
});
}
validatedSelectedModelChecks = normalized;
}
return wrapTeamHandler('prepareProvisioning', () =>
getTeamProvisioningService().prepareForProvisioning(validatedCwd, {
providerId: validatedProviderId,
@ -2443,6 +2491,7 @@ async function handlePrepareProvisioning(
modelIds: validatedSelectedModels,
limitContext: validatedLimitContext,
modelVerificationMode: validatedModelVerificationMode,
modelChecks: validatedSelectedModelChecks,
})
);
}

View file

@ -679,6 +679,11 @@ export class ClaudeMultimodelBridgeService {
private readonly providerStatusHydrationGenerations = new Map<CliProviderId, number>();
private readonly providerStatusHydrationInFlight = new Map<
CliProviderId,
{ readonly generation: number; readonly promise: Promise<CliProviderStatus> }
>();
private beginProviderStatusHydration(providerIds: readonly CliProviderId[]): number {
const generation = ++this.providerStatusHydrationGeneration;
for (const providerId of providerIds) {
@ -691,6 +696,38 @@ export class ClaudeMultimodelBridgeService {
return this.providerStatusHydrationGenerations.get(providerId) === generation;
}
private getProviderCatalogHydration(
binaryPath: string,
providerId: CliProviderId,
generation: number
): Promise<CliProviderStatus | null> {
const inFlight = this.providerStatusHydrationInFlight.get(providerId);
if (inFlight) {
if (inFlight.generation === generation) {
return inFlight.promise;
}
return inFlight.promise
.catch(() => undefined)
.then(() => {
if (!this.isProviderStatusHydrationCurrent(providerId, generation)) {
return null;
}
return this.getProviderCatalogHydration(binaryPath, providerId, generation);
});
}
const request = this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId).finally(
() => {
if (this.providerStatusHydrationInFlight.get(providerId)?.promise === request) {
this.providerStatusHydrationInFlight.delete(providerId);
}
}
);
this.providerStatusHydrationInFlight.set(providerId, { generation, promise: request });
return request;
}
private async buildCliEnv(
binaryPath: string
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
@ -708,18 +745,23 @@ export class ClaudeMultimodelBridgeService {
});
}
private isUnifiedRuntimeUnsupported(error: unknown): boolean {
private isRuntimeStatusCompatibilityError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
return (
lower.includes('unknown command') ||
lower.includes('unknown option') ||
lower.includes('no such command') ||
lower.includes('did you mean') ||
lower.includes('runtime status')
lower.includes('did you mean')
);
}
private isUnifiedRuntimeUnsupported(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
return this.isRuntimeStatusCompatibilityError(error) || lower.includes('runtime status');
}
private mapRuntimeProviderStatus(
providerId: CliProviderId,
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
@ -961,8 +1003,11 @@ export class ClaudeMultimodelBridgeService {
continue;
}
void this.getProviderStatusFromScopedRuntimeStatus(binaryPath, liveProvider.providerId)
void this.getProviderCatalogHydration(binaryPath, liveProvider.providerId, generation)
.then((hydratedProvider) => {
if (!hydratedProvider) {
return;
}
if (!this.isProviderStatusHydrationCurrent(liveProvider.providerId, generation)) {
return;
}
@ -1100,8 +1145,11 @@ export class ClaudeMultimodelBridgeService {
summary: true,
});
if (provider.runtimeCapabilities?.modelCatalog?.dynamic === true && onCatalogUpdate) {
void this.getProviderStatusFromScopedRuntimeStatus(binaryPath, provider.providerId)
void this.getProviderCatalogHydration(binaryPath, provider.providerId, generation)
.then((hydratedProvider) => {
if (!hydratedProvider) {
return;
}
if (!this.isProviderStatusHydrationCurrent(provider.providerId, generation)) {
return;
}
@ -1124,24 +1172,35 @@ export class ClaudeMultimodelBridgeService {
}
return provider;
} catch (error) {
if (!this.isUnifiedRuntimeUnsupported(error)) {
if (providerId === 'gemini' && this.isRuntimeStatusCompatibilityError(error)) {
return this.buildGeminiStatus(binaryPath);
}
if (this.isRuntimeStatusCompatibilityError(error)) {
logger.warn(
`Provider-scoped summary runtime status unavailable for ${providerId}, falling back to full probe: ${
error instanceof Error ? error.message : String(error)
}`
);
try {
return await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId);
} catch (fullError) {
logger.warn(
`Provider-scoped full runtime status unavailable for ${providerId}, returning scoped error: ${
fullError instanceof Error ? fullError.message : String(fullError)
}`
);
return createRuntimeStatusErrorProviderStatus(providerId, fullError);
}
}
}
if (providerId === 'gemini') {
return this.buildGeminiStatus(binaryPath);
logger.warn(
`Provider-scoped summary runtime status unavailable for ${providerId}, returning scoped error: ${
error instanceof Error ? error.message : String(error)
}`
);
return createRuntimeStatusErrorProviderStatus(providerId, error);
}
const providers = await this.getProviderStatuses(binaryPath);
return (
providers.find((provider) => provider.providerId === providerId) ??
createDefaultProviderStatus(providerId)
);
}
async verifyProviderStatus(

View file

@ -219,6 +219,33 @@ function firstEvidence(parts: readonly string[], pattern: RegExp): string[] {
const WORKSPACE_TRUST_FAILURE_PATTERN =
/workspace trust is not accepted|cannot start in headless process runtime because workspace trust|open that workspace once interactively and accept trust|workspace_trust_preflight_not_confirmed|workspace trust was not confirmed|workspace trust preflight blocked launch/i;
const BOOTSTRAP_TRANSPORT_EVIDENCE_PATTERN = new RegExp(
[
'mailbox_bootstrap_written',
'bootstrap_prompt_observed',
'bootstrap_submit_attempted',
'bootstrap_submitted',
'inbox_poller_ready',
'runtime_events_log',
].join('|'),
'i'
);
const MODEL_NO_BOOTSTRAP_PATTERN = new RegExp(
[
'did not bootstrap-confirm',
'bootstrap unconfirmed',
'bootstrap-confirm before timeout',
'bootstrap was not confirmed',
'bootstrap not confirmed',
'check-in not yet received',
'bootstrap_stalled',
'timed out waiting for bootstrap_submitted',
'last transport stage:\\s*(?:mailbox_bootstrap_written|bootstrap_prompt_observed|bootstrap_submit_attempted|bootstrap_submitted)',
].join('|'),
'i'
);
export function isWorkspaceTrustLaunchFailureText(value: string): boolean {
return WORKSPACE_TRUST_FAILURE_PATTERN.test(value);
}
@ -228,6 +255,7 @@ export function classifyLaunchFailureArtifact(
): LaunchFailureArtifactClassification {
const parts = collectLaunchFailureSearchParts(input);
const text = parts.join('\n').toLowerCase();
const hasBootstrapTransportEvidence = BOOTSTRAP_TRANSPORT_EVIDENCE_PATTERN.test(text);
const candidates: {
code: LaunchFailureArtifactClassificationCode;
confidence: number;
@ -268,8 +296,7 @@ export function classifyLaunchFailureArtifact(
{
code: 'model_no_bootstrap',
confidence: 0.82,
pattern:
/did not bootstrap-confirm|bootstrap unconfirmed|bootstrap-confirm before timeout|bootstrap was not confirmed|bootstrap not confirmed|check-in not yet received|bootstrap_stalled/i,
pattern: MODEL_NO_BOOTSTRAP_PATTERN,
},
{
code: 'process_exited',
@ -279,6 +306,9 @@ export function classifyLaunchFailureArtifact(
];
for (const candidate of candidates) {
if (candidate.code === 'stdin_missing' && hasBootstrapTransportEvidence) {
continue;
}
if (candidate.pattern.test(text)) {
return {
code: candidate.code,
@ -305,7 +335,7 @@ export function extractLaunchBootstrapTransportBreadcrumb(
];
const evidence = firstEvidence(
parts,
/bootstrap_submit_|last transport stage|no stdin data received|local prompt handler/i
/bootstrap_submit_|mailbox_bootstrap_written|bootstrap_prompt_observed|bootstrap_submitted|last transport stage|no stdin data received|local prompt handler/i
).map(redactLaunchFailureArtifactText);
const retryableRaw = retryableMatches.at(-1)?.[1]?.toLowerCase();
return {

View file

@ -432,6 +432,7 @@ export class TeamMcpConfigBuilder {
[MCP_SERVER_NAME]: {
command: launchSpec.command,
args: launchSpec.args,
enabled: true,
env: {
[MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(),
},

View file

@ -7,6 +7,7 @@ import {
type OpenCodeFilePart,
} from '@features/agent-attachments/main';
import {
resolveAnthropicEffortSupport,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '@features/anthropic-runtime-profile/main';
@ -568,6 +569,7 @@ import type {
TeamMember,
TeamProviderBackendId,
TeamProviderId,
TeamProvisioningModelCheckRequest,
TeamProvisioningModelVerificationMode,
TeamProvisioningPrepareIssue,
TeamProvisioningPrepareResult,
@ -1328,6 +1330,11 @@ interface RuntimeProviderLaunchFacts {
| null;
}
interface ProviderSelectedModelCheck {
modelId: string;
effort?: EffortLevel;
}
function extractJsonObjectFromCli<T>(raw: string): T {
const trimmed = raw.trim();
try {
@ -1389,6 +1396,58 @@ function normalizeProviderModelListModels(
return models;
}
function normalizeProviderSelectedModelChecks(
modelIds: readonly string[],
modelChecks?: readonly ProviderSelectedModelCheck[]
): ProviderSelectedModelCheck[] {
const checks: ProviderSelectedModelCheck[] =
modelChecks && modelChecks.length > 0
? [...modelChecks]
: modelIds.map((modelId) => ({ modelId }));
const seen = new Set<string>();
const normalized: ProviderSelectedModelCheck[] = [];
for (const check of checks) {
const modelId = check.modelId.trim();
if (!modelId) {
continue;
}
const key = `${modelId}\n${check.effort ?? ''}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
normalized.push({
modelId,
...(check.effort ? { effort: check.effort } : {}),
});
}
return normalized;
}
function normalizeProvisioningModelCheckRequests(
checks: readonly TeamProvisioningModelCheckRequest[] | undefined
): TeamProvisioningModelCheckRequest[] {
const seen = new Set<string>();
const normalized: TeamProvisioningModelCheckRequest[] = [];
for (const check of checks ?? []) {
const model = check.model.trim();
if (!model) {
continue;
}
const key = `${check.providerId}\n${model}\n${check.effort ?? ''}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
normalized.push({
providerId: check.providerId,
model,
...(check.effort ? { effort: check.effort } : {}),
});
}
return normalized;
}
function addModelCatalogLaunchModels(
modelIds: Set<string>,
catalog: CliProviderModelCatalog
@ -1444,7 +1503,7 @@ function getAnthropicFastModeDefault(): boolean {
function resolveAnthropicSelectionFromFacts(params: {
selectedModel?: string;
limitContext?: boolean;
facts: Pick<RuntimeProviderLaunchFacts, 'modelCatalog' | 'runtimeCapabilities'>;
facts: Pick<RuntimeProviderLaunchFacts, 'modelCatalog' | 'modelIds' | 'runtimeCapabilities'>;
}) {
return resolveAnthropicRuntimeSelection({
source: {
@ -1453,9 +1512,33 @@ function resolveAnthropicSelectionFromFacts(params: {
},
selectedModel: params.selectedModel,
limitContext: params.limitContext === true,
availableLaunchModels: params.facts.modelCatalog ? undefined : params.facts.modelIds,
});
}
function formatAnthropicEffortSupportFailure(params: {
effort: EffortLevel;
modelLabel: string;
supportedEfforts?: readonly EffortLevel[];
kind:
| 'unsupported-by-catalog'
| 'unsupported-by-runtime-capability'
| 'unverified-catalog-missing';
}): string {
if (params.kind === 'unverified-catalog-missing') {
return `Anthropic runtime catalog was unavailable, so effort "${params.effort}" for ${params.modelLabel} could not be verified.`;
}
const supported = params.supportedEfforts?.length
? ` Supported efforts: ${params.supportedEfforts.join(', ')}.`
: '';
const runtimeSuffix =
params.kind === 'unsupported-by-runtime-capability'
? ' in the current runtime capability data'
: ' in the current runtime';
return `${params.modelLabel} does not support Anthropic effort "${params.effort}"${runtimeSuffix}.${supported}`;
}
function resolveCodexSelectionFromFacts(params: {
selectedModel?: string;
providerBackendId?: TeamCreateRequest['providerBackendId'];
@ -4467,6 +4550,33 @@ interface RuntimeBootstrapSpec {
};
}
const DETERMINISTIC_BOOTSTRAP_MIN_TIMEOUT_MS = 120_000;
const DETERMINISTIC_BOOTSTRAP_TIMEOUT_PER_MEMBER_MS = 60_000;
const DETERMINISTIC_BOOTSTRAP_MAX_TIMEOUT_MS = 300_000;
const DETERMINISTIC_BOOTSTRAP_OUTER_TIMEOUT_GRACE_MS = 30_000;
function getDeterministicBootstrapTimeoutMs(memberCount: number): number {
const perMemberBudget = Math.max(0, memberCount) * DETERMINISTIC_BOOTSTRAP_TIMEOUT_PER_MEMBER_MS;
return Math.min(
DETERMINISTIC_BOOTSTRAP_MAX_TIMEOUT_MS,
Math.max(DETERMINISTIC_BOOTSTRAP_MIN_TIMEOUT_MS, perMemberBudget)
);
}
function getProvisioningRunTimeoutMs(
run: Pick<ProvisioningRun, 'deterministicBootstrap' | 'effectiveMembers'>
): number {
if (!run.deterministicBootstrap) {
return RUN_TIMEOUT_MS;
}
return Math.max(
RUN_TIMEOUT_MS,
getDeterministicBootstrapTimeoutMs(run.effectiveMembers.length) +
DETERMINISTIC_BOOTSTRAP_OUTER_TIMEOUT_GRACE_MS
);
}
function buildDeterministicCreateBootstrapSpec(
runId: string,
request: TeamCreateRequest,
@ -4516,6 +4626,7 @@ function buildDeterministicCreateBootstrapSpec(
: {}),
})),
launch: {
bootstrapTimeoutMs: getDeterministicBootstrapTimeoutMs(effectiveMembers.length),
continueOnPartialFailure: true,
},
ui: {
@ -4570,6 +4681,7 @@ function buildDeterministicLaunchBootstrapSpec(
: {}),
})),
launch: {
bootstrapTimeoutMs: getDeterministicBootstrapTimeoutMs(effectiveMembers.length),
continueOnPartialFailure: true,
},
ui: {
@ -7342,11 +7454,28 @@ export class TeamProvisioningService {
`${params.actorLabel} resolves to Anthropic model "${resolvedLaunchModel}", but the current runtime does not list it as launchable.`
);
}
if (params.effort && !selection.supportedEfforts.includes(params.effort)) {
if (params.effort) {
const modelLabel = selection.displayName ?? resolvedLaunchModel;
throw new Error(
`${params.actorLabel} uses Anthropic effort "${params.effort}", but ${modelLabel} does not support it in the current runtime.`
);
const effortSupport = resolveAnthropicEffortSupport({
selection,
effort: params.effort,
runtimeCapabilities: params.facts.runtimeCapabilities,
});
if (effortSupport.kind !== 'supported') {
throw new Error(
`${params.actorLabel} uses Anthropic effort "${params.effort}", but ${formatAnthropicEffortSupportFailure(
{
effort: params.effort,
modelLabel,
kind: effortSupport.kind,
supportedEfforts:
effortSupport.kind === 'unverified-catalog-missing'
? undefined
: effortSupport.supportedEfforts,
}
)}`
);
}
}
const fastResolution = resolveAnthropicFastMode({
@ -19000,6 +19129,7 @@ export class TeamProvisioningService {
providerId?: TeamProviderId;
providerIds?: TeamProviderId[];
modelIds?: string[];
modelChecks?: TeamProvisioningModelCheckRequest[];
limitContext?: boolean;
modelVerificationMode?: TeamProvisioningModelVerificationMode;
}
@ -19026,6 +19156,7 @@ export class TeamProvisioningService {
providerId?: TeamProviderId;
providerIds?: TeamProviderId[];
modelIds?: string[];
modelChecks?: TeamProvisioningModelCheckRequest[];
limitContext?: boolean;
modelVerificationMode?: TeamProvisioningModelVerificationMode;
}
@ -19040,11 +19171,24 @@ export class TeamProvisioningService {
const modelIds = Array.from(
new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean))
);
const modelChecks = normalizeProvisioningModelCheckRequests(opts?.modelChecks)
.map((check) => ({
providerId: check.providerId,
model: check.model,
effort: check.effort ?? null,
}))
.sort(
(left, right) =>
left.providerId.localeCompare(right.providerId) ||
left.model.localeCompare(right.model) ||
(left.effort ?? '').localeCompare(right.effort ?? '')
);
return JSON.stringify({
cwd: cwd?.trim() || process.cwd(),
forceFresh: opts?.forceFresh === true,
providerIds,
modelIds,
modelChecks,
limitContext: opts?.limitContext === true,
modelVerificationMode: opts?.modelVerificationMode ?? null,
});
@ -19068,6 +19212,7 @@ export class TeamProvisioningService {
providerId?: TeamProviderId;
providerIds?: TeamProviderId[];
modelIds?: string[];
modelChecks?: TeamProvisioningModelCheckRequest[];
limitContext?: boolean;
modelVerificationMode?: TeamProvisioningModelVerificationMode;
}
@ -19104,8 +19249,20 @@ export class TeamProvisioningService {
const selectedModelIds = Array.from(
new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean))
);
const selectedModelChecks = normalizeProvisioningModelCheckRequests(opts?.modelChecks);
const useStructuredModelChecks = selectedModelChecks.length > 0;
for (const providerId of providerIds) {
const providerModelChecks = selectedModelChecks
.filter((check) => check.providerId === providerId)
.map((check) => ({
modelId: check.model,
...(check.effort ? { effort: check.effort } : {}),
}));
const providerSelectedModelIds = useStructuredModelChecks
? Array.from(new Set(providerModelChecks.map((check) => check.modelId)))
: selectedModelIds;
if (providerId === 'opencode') {
const adapter = this.getOpenCodeRuntimeAdapter();
if (!adapter) {
@ -19115,7 +19272,7 @@ export class TeamProvisioningService {
continue;
}
if (selectedModelIds.length === 0) {
if (providerSelectedModelIds.length === 0) {
const prepare = await adapter.prepare({
runId: `prepare-${randomUUID()}`,
teamName: '__prepare_opencode__',
@ -19149,7 +19306,7 @@ export class TeamProvisioningService {
const openCodeModelPrepare = await this.prepareSelectedOpenCodeModels({
adapter,
cwd: targetCwd,
modelIds: selectedModelIds,
modelIds: providerSelectedModelIds,
verificationMode: opts?.modelVerificationMode ?? 'deep',
});
details.push(...openCodeModelPrepare.details);
@ -19176,7 +19333,7 @@ export class TeamProvisioningService {
}
const appendSelectedModelVerification = async (): Promise<void> => {
if (selectedModelIds.length === 0) {
if (providerSelectedModelIds.length === 0) {
return;
}
@ -19184,12 +19341,14 @@ export class TeamProvisioningService {
claudePath: probeResult.claudePath,
cwd: targetCwd,
providerId,
modelIds: selectedModelIds,
modelIds: providerSelectedModelIds,
modelChecks: providerModelChecks,
limitContext: opts?.limitContext === true,
});
details.push(...modelVerification.details);
warnings.push(...modelVerification.warnings);
blockingMessages.push(...modelVerification.blockingMessages);
issues.push(...(modelVerification.issues ?? []));
};
const appendOneShotDiagnostic = async (): Promise<void> => {
@ -19298,7 +19457,7 @@ export class TeamProvisioningService {
// Preflight warnings (including timeouts) should not block provisioning.
warnings.push(prefixedWarning);
const blockingCountBeforeModelChecks = blockingMessages.length;
if (!isBlockingPreflightWarning && selectedModelIds.length > 0) {
if (!isBlockingPreflightWarning && providerSelectedModelIds.length > 0) {
await appendSelectedModelVerification();
}
if (
@ -19964,24 +20123,29 @@ export class TeamProvisioningService {
cwd,
providerId,
modelIds,
modelChecks,
limitContext,
}: {
claudePath: string;
cwd: string;
providerId: TeamProviderId;
modelIds: string[];
modelChecks?: ProviderSelectedModelCheck[];
limitContext: boolean;
}): Promise<{
details: string[];
warnings: string[];
blockingMessages: string[];
issues?: TeamProvisioningPrepareIssue[];
}> {
const details: string[] = [];
const warnings: string[] = [];
const blockingMessages: string[] = [];
const issues: TeamProvisioningPrepareIssue[] = [];
const startedAt = Date.now();
const selectedModelChecks = normalizeProviderSelectedModelChecks(modelIds, modelChecks);
if (modelIds.length === 0) {
if (selectedModelChecks.length === 0) {
return { details, warnings, blockingMessages };
}
@ -20013,35 +20177,99 @@ export class TeamProvisioningService {
return;
}
blockingMessages.push(`Selected model ${requestedModelId} is unavailable. ${outcome.reason}`);
issues.push({
providerId,
modelId: requestedModelId,
scope: 'model',
severity: 'blocking',
code: 'model_unavailable',
message: outcome.reason,
});
};
const recordAnthropicEffortOutcome = (
requestedModelId: string,
effort: EffortLevel
): boolean => {
const selection = resolveAnthropicSelectionFromFacts({
selectedModel: requestedModelId,
limitContext,
facts: runtimeFacts,
});
const modelLabel = selection.displayName ?? selection.resolvedLaunchModel ?? requestedModelId;
const effortSupport = resolveAnthropicEffortSupport({
selection,
effort,
runtimeCapabilities: runtimeFacts.runtimeCapabilities,
});
if (effortSupport.kind === 'supported') {
return true;
}
const reason = formatAnthropicEffortSupportFailure({
effort,
modelLabel,
kind: effortSupport.kind,
supportedEfforts:
effortSupport.kind === 'unverified-catalog-missing'
? undefined
: effortSupport.supportedEfforts,
});
blockingMessages.push(`Selected model ${requestedModelId} is unavailable. ${reason}`);
issues.push({
providerId,
modelId: requestedModelId,
scope: 'model',
severity: 'blocking',
code:
effortSupport.kind === 'unverified-catalog-missing'
? 'effort_unverified'
: 'effort_unsupported',
message: reason,
});
return false;
};
appendPreflightDebugLog('provider_model_catalog_check_start', {
providerId,
cwd,
modelIds,
modelIds: selectedModelChecks.map((check) => check.modelId),
});
for (const modelId of modelIds) {
const label = modelId.trim();
const checksByModelId = new Map<string, ProviderSelectedModelCheck[]>();
for (const check of selectedModelChecks) {
const label = check.modelId.trim();
if (!label) {
continue;
}
checksByModelId.set(label, [...(checksByModelId.get(label) ?? []), check]);
}
recordOutcome(
label,
this.resolveProviderCompatibilityModel({
providerId,
requestedModelId: label,
runtimeFacts,
limitContext,
})
);
for (const [label, checks] of checksByModelId.entries()) {
const outcome = this.resolveProviderCompatibilityModel({
providerId,
requestedModelId: label,
runtimeFacts,
limitContext,
});
let effortSupported = true;
if (outcome.kind !== 'unavailable' && providerId === 'anthropic') {
for (const check of checks) {
if (check.effort && !recordAnthropicEffortOutcome(label, check.effort)) {
effortSupported = false;
}
}
}
if (!effortSupported) {
continue;
}
recordOutcome(label, outcome);
}
appendPreflightDebugLog('provider_model_catalog_check_complete', {
providerId,
cwd,
modelIds,
modelIds: selectedModelChecks.map((check) => check.modelId),
durationMs: Date.now() - startedAt,
modelCount: runtimeFacts.modelIds.size,
details,
@ -20049,7 +20277,12 @@ export class TeamProvisioningService {
blockingMessages,
});
return { details, warnings, blockingMessages };
return {
details,
warnings,
blockingMessages,
...(issues.length > 0 ? { issues } : {}),
};
}
private async resolveProviderDefaultModel(
@ -20999,7 +21232,7 @@ export class TeamProvisioningService {
this.cleanupRun(run);
})();
}
}, RUN_TIMEOUT_MS);
}, getProvisioningRunTimeoutMs(run));
child.once('error', (error) => {
const hint = run.isLaunch ? ' (launch)' : '';
@ -21795,7 +22028,7 @@ export class TeamProvisioningService {
this.cleanupRun(run);
})();
}
}, RUN_TIMEOUT_MS);
}, getProvisioningRunTimeoutMs(run));
child.once('error', (error) => {
const progress = updateProgress(run, 'failed', 'Failed to start Claude CLI', {
@ -23098,7 +23331,7 @@ export class TeamProvisioningService {
this.cleanupRun(run);
})();
}
}, RUN_TIMEOUT_MS);
}, getProvisioningRunTimeoutMs(run));
child.once('error', (error) => {
const progress = updateProgress(run, 'failed', 'Failed to start Claude CLI (launch)', {

View file

@ -331,6 +331,7 @@ import type {
TeamLaunchResponse,
TeamMemberActivityMeta,
TeamMessageNotificationData,
TeamProvisioningModelCheckRequest,
TeamProvisioningModelVerificationMode,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
@ -927,7 +928,8 @@ const electronAPI: ElectronAPI = {
providerIds?: TeamLaunchRequest['providerId'][],
selectedModels?: string[],
limitContext?: boolean,
modelVerificationMode?: TeamProvisioningModelVerificationMode
modelVerificationMode?: TeamProvisioningModelVerificationMode,
selectedModelChecks?: TeamProvisioningModelCheckRequest[]
) => {
return invokeIpcWithResult<TeamProvisioningPrepareResult>(
TEAM_PREPARE_PROVISIONING,
@ -936,7 +938,8 @@ const electronAPI: ElectronAPI = {
providerIds,
selectedModels,
limitContext,
modelVerificationMode
modelVerificationMode,
selectedModelChecks
);
},
getWorktreeGitStatus: async (projectPath: string) => {

View file

@ -149,7 +149,7 @@ export const CollapsibleTeamSection = memo(function CollapsibleTeamSection({
{headerExtra}
</div>
{action && (
<div className="relative z-10 flex shrink-0 items-center self-start">{action}</div>
<div className="relative z-10 flex shrink-0 items-center self-stretch">{action}</div>
)}
</div>
{keepMounted ? (

View file

@ -110,6 +110,7 @@ import {
} from './providerPrepareDiagnostics';
import {
buildProviderPrepareMembersSignature,
buildProviderPrepareModelChecksSignature,
buildProviderPrepareRequestSignature,
buildProviderPrepareRuntimeStatusSignature,
} from './providerPrepareRequestSignature';
@ -146,6 +147,14 @@ import {
} from './WorktreeGitReadinessBanner';
import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection';
import type {
EffortLevel,
TeamCreateRequest,
TeamFastMode,
TeamProviderId,
TeamProvisioningMemberInput,
TeamProvisioningModelCheckRequest,
} from '@shared/types';
const TEAM_COLOR_NAMES = [
'blue',
@ -160,14 +169,6 @@ const TEAM_COLOR_NAMES = [
const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate';
import type {
EffortLevel,
TeamCreateRequest,
TeamFastMode,
TeamProviderId,
TeamProvisioningMemberInput,
} from '@shared/types';
function getProviderLabel(providerId: TeamProviderId): string {
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
}
@ -737,6 +738,85 @@ export const CreateTeamDialog = ({
() => buildProviderPrepareMembersSignature(effectiveMemberDrafts),
[effectiveMemberDrafts]
);
const selectedModelChecksByProvider = useMemo(() => {
const modelsByProvider = new Map<TeamProviderId, TeamProvisioningModelCheckRequest[]>();
const leadEffort = (selectedEffort as EffortLevel | '') || undefined;
const addModel = (
providerId: TeamProviderId,
model: string | undefined,
effort?: EffortLevel
): void => {
const trimmed = model?.trim() ?? '';
if (!trimmed) {
return;
}
const existing = modelsByProvider.get(providerId) ?? [];
if (!existing.some((entry) => entry.model === trimmed && entry.effort === effort)) {
modelsByProvider.set(providerId, [
...existing,
{
providerId,
model: trimmed,
...(effort ? { effort } : {}),
},
]);
}
};
const addDefaultSelection = (providerId: TeamProviderId, effort?: EffortLevel): void => {
if (
providerId === 'codex' ||
providerId === 'gemini' ||
(providerId === 'anthropic' && selectedProviderId === 'anthropic')
) {
addModel(providerId, DEFAULT_PROVIDER_MODEL_SELECTION, effort);
}
};
const leadModel = computeEffectiveTeamModel(
selectedModel,
effectiveAnthropicRuntimeLimitContext,
selectedProviderId
);
if (selectedModel.trim()) {
addModel(selectedProviderId, leadModel, leadEffort);
} else {
addDefaultSelection(selectedProviderId, leadEffort);
}
for (const member of effectiveMemberDrafts) {
if (member.removedAt) {
continue;
}
const memberProviderId = normalizeOptionalTeamProviderId(member.providerId);
const inheritsDefaultRuntime = !memberProviderId || memberProviderId === selectedProviderId;
const explicitMemberModel = member.model?.trim() ?? '';
const memberEffort =
member.effort ?? (inheritsDefaultRuntime && !explicitMemberModel ? leadEffort : undefined);
const scopedModel = resolveProviderScopedMemberModel({
memberProviderId: member.providerId,
memberModel: member.model,
selectedProviderId,
runtimeProviderStatusById,
});
if (scopedModel.model) {
addModel(scopedModel.providerId, scopedModel.model, memberEffort);
} else {
addDefaultSelection(scopedModel.providerId, memberEffort);
}
}
return modelsByProvider;
}, [
effectiveAnthropicRuntimeLimitContext,
effectiveMemberDrafts,
runtimeProviderStatusById,
selectedEffort,
selectedModel,
selectedProviderId,
]);
const selectedModelChecksByProviderSignature = useMemo(
() => buildProviderPrepareModelChecksSignature(selectedModelChecksByProvider),
[selectedModelChecksByProvider]
);
const prepareRequestSignature = useMemo(
() =>
buildProviderPrepareRequestSignature({
@ -747,6 +827,7 @@ export const CreateTeamDialog = ({
limitContext: effectiveAnthropicRuntimeLimitContext,
runtimeStatusSignature: prepareRuntimeStatusSignature,
membersSignature: prepareMembersSignature,
modelChecksSignature: selectedModelChecksByProviderSignature,
}),
[
effectiveCwd,
@ -755,6 +836,7 @@ export const CreateTeamDialog = ({
prepareRuntimeStatusSignature,
selectedMemberProviders,
selectedModel,
selectedModelChecksByProviderSignature,
selectedProviderId,
]
);
@ -775,6 +857,7 @@ export const CreateTeamDialog = ({
backendSummary,
limitContext: effectiveAnthropicRuntimeLimitContext,
runtimeStatusSignature: prepareRuntimeStatusSignature,
modelChecksSignature: selectedModelChecksByProviderSignature,
});
const issueReasons = getShortLivedProviderPrepareModelIssueReasons({
providerId,
@ -802,6 +885,7 @@ export const CreateTeamDialog = ({
prepareChecks,
prepareRuntimeStatusSignature,
runtimeBackendSummaryByProvider,
selectedModelChecksByProviderSignature,
selectedMemberProviders,
]);
@ -903,49 +987,8 @@ export const CreateTeamDialog = ({
void (async () => {
let checks = initialChecks;
const providerPlans = selectedMemberProviders.map((providerId) => {
const selectedModelChecks = (() => {
const next = new Set<string>();
let hasDefaultSelection = false;
const supportsProviderDefaultCheck =
providerId === 'codex' ||
providerId === 'gemini' ||
(providerId === 'anthropic' && selectedProviderId === 'anthropic');
const leadModel = computeEffectiveTeamModel(
selectedModel,
effectiveAnthropicRuntimeLimitContext,
selectedProviderId
);
if (selectedProviderId === providerId && selectedModel.trim()) {
if (leadModel?.trim()) {
next.add(leadModel.trim());
}
} else if (selectedProviderId === providerId && supportsProviderDefaultCheck) {
hasDefaultSelection = true;
}
for (const member of effectiveMemberDrafts) {
if (member.removedAt) {
continue;
}
const scopedModel = resolveProviderScopedMemberModel({
memberProviderId: member.providerId,
memberModel: member.model,
selectedProviderId,
runtimeProviderStatusById,
});
if (scopedModel.providerId !== providerId) {
continue;
}
if (scopedModel.model) {
next.add(scopedModel.model);
} else if (supportsProviderDefaultCheck) {
hasDefaultSelection = true;
}
}
if (supportsProviderDefaultCheck && hasDefaultSelection) {
next.add(DEFAULT_PROVIDER_MODEL_SELECTION);
}
return Array.from(next);
})();
const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
const selectedModelIds = selectedModelChecks.map((check) => check.model);
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
const cacheKey = buildProviderPrepareModelCacheKey({
cwd: effectiveCwd,
@ -953,6 +996,7 @@ export const CreateTeamDialog = ({
backendSummary,
limitContext: effectiveAnthropicRuntimeLimitContext,
runtimeStatusSignature: prepareRuntimeStatusSignature,
modelChecksSignature: selectedModelChecksByProviderSignature,
});
const cachedModelResultsById = {
...getShortLivedProviderPrepareModelResults({
@ -963,12 +1007,13 @@ export const CreateTeamDialog = ({
};
const cachedSnapshot = getProviderPrepareCachedSnapshot({
providerId,
selectedModelIds: selectedModelChecks,
selectedModelIds,
cachedModelResultsById,
});
return {
providerId,
selectedModelChecks,
selectedModelIds,
backendSummary,
cacheKey,
cachedModelResultsById,
@ -979,7 +1024,7 @@ export const CreateTeamDialog = ({
try {
for (const plan of providerPlans) {
checks = updateProviderCheck(checks, plan.providerId, {
status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking',
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
backendSummary: plan.backendSummary,
details: plan.cachedSnapshot.details,
});
@ -992,7 +1037,8 @@ export const CreateTeamDialog = ({
const prepResult = await runProviderPrepareDiagnostics({
cwd: effectiveCwd,
providerId: plan.providerId,
selectedModelIds: plan.selectedModelChecks,
selectedModelIds: plan.selectedModelIds,
selectedModelChecks: plan.selectedModelChecks,
prepareProvisioning: api.teams.prepareProvisioning,
limitContext: effectiveAnthropicRuntimeLimitContext,
cachedModelResultsById: plan.cachedModelResultsById,
@ -1059,14 +1105,14 @@ export const CreateTeamDialog = ({
anyFailure
? failureMessage
: anyNotes
? 'Selected providers are ready with notes.'
: 'Selected providers are ready.'
? 'All selected providers are ready, with notes.'
: 'All selected providers are ready.'
);
setPrepareWarnings(collectedWarnings);
} catch (error) {
if (prepareRequestSeqRef.current !== requestSeq) return;
const failureMessage =
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
error instanceof Error ? error.message : 'Failed to prepare selected providers';
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
@ -1085,6 +1131,8 @@ export const CreateTeamDialog = ({
prepareRuntimeStatusSignature,
runtimeProviderStatusById,
selectedModel,
selectedModelChecksByProvider,
selectedModelChecksByProviderSignature,
selectedProviderId,
selectedMemberProviders,
]);
@ -2325,7 +2373,7 @@ export const CreateTeamDialog = ({
<span>
{effectivePrepare.message ??
(effectivePrepare.state === 'idle'
? 'Warming up CLI environment...'
? 'Checking selected providers...'
: 'Preparing environment...')}
</span>
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
@ -2344,8 +2392,8 @@ export const CreateTeamDialog = ({
<span>
{prepareChecks.some((check) => check.status === 'notes') ||
prepareWarnings.length > 0
? 'CLI environment ready (with notes)'
: 'CLI environment ready'}
? 'Selected providers ready (with notes)'
: 'Selected providers ready'}
</span>
</div>
{effectivePrepare.message ? (

View file

@ -166,6 +166,7 @@ import type {
TeamFastMode,
TeamLaunchRequest,
TeamProviderId,
TeamProvisioningModelCheckRequest,
UpdateSchedulePatch,
} from '@shared/types';
@ -1133,37 +1134,53 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
]);
const selectedModelChecksByProvider = useMemo(() => {
const modelsByProvider = new Map<TeamProviderId, string[]>();
const defaultSelectionByProvider = new Map<TeamProviderId, boolean>();
const addModel = (providerId: TeamProviderId, model: string | undefined): void => {
const modelsByProvider = new Map<TeamProviderId, TeamProvisioningModelCheckRequest[]>();
const leadEffort = (selectedEffort as EffortLevel | '') || undefined;
const addModel = (
providerId: TeamProviderId,
model: string | undefined,
effort?: EffortLevel
): void => {
const trimmed = model?.trim() ?? '';
if (!trimmed) {
return;
}
const existing = modelsByProvider.get(providerId) ?? [];
if (!existing.includes(trimmed)) {
modelsByProvider.set(providerId, [...existing, trimmed]);
if (!existing.some((entry) => entry.model === trimmed && entry.effort === effort)) {
modelsByProvider.set(providerId, [
...existing,
{
providerId,
model: trimmed,
...(effort ? { effort } : {}),
},
]);
}
};
const addDefaultSelection = (providerId: TeamProviderId): void => {
const addDefaultSelection = (providerId: TeamProviderId, effort?: EffortLevel): void => {
if (
providerId === 'codex' ||
providerId === 'gemini' ||
(providerId === 'anthropic' && selectedProviderId === 'anthropic')
) {
defaultSelectionByProvider.set(providerId, true);
addModel(providerId, DEFAULT_PROVIDER_MODEL_SELECTION, effort);
}
};
if (selectedModel.trim()) {
addModel(selectedProviderId, effectiveLeadRuntimeModel);
addModel(selectedProviderId, effectiveLeadRuntimeModel, leadEffort);
} else {
addDefaultSelection(selectedProviderId);
addDefaultSelection(selectedProviderId, leadEffort);
}
for (const member of effectiveMemberDrafts) {
if (member.removedAt) {
continue;
}
const memberProviderId = normalizeOptionalTeamProviderId(member.providerId);
const inheritsDefaultRuntime = !memberProviderId || memberProviderId === selectedProviderId;
const explicitMemberModel = member.model?.trim() ?? '';
const memberEffort =
member.effort ?? (inheritsDefaultRuntime && !explicitMemberModel ? leadEffort : undefined);
const scopedModel = resolveProviderScopedMemberModel({
memberProviderId: member.providerId,
memberModel: member.model,
@ -1171,20 +1188,18 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
runtimeProviderStatusById,
});
if (scopedModel.model) {
addModel(scopedModel.providerId, scopedModel.model);
addModel(scopedModel.providerId, scopedModel.model, memberEffort);
} else {
addDefaultSelection(scopedModel.providerId);
addDefaultSelection(scopedModel.providerId, memberEffort);
}
}
for (const providerId of defaultSelectionByProvider.keys()) {
addModel(providerId, DEFAULT_PROVIDER_MODEL_SELECTION);
}
return modelsByProvider;
}, [
effectiveLeadRuntimeModel,
effectiveMemberDrafts,
runtimeProviderStatusById,
selectedEffort,
selectedModel,
selectedProviderId,
]);
@ -1470,6 +1485,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
backendSummary,
limitContext: effectiveAnthropicRuntimeLimitContext,
runtimeStatusSignature: prepareRuntimeStatusSignature,
modelChecksSignature: selectedModelChecksByProviderSignature,
});
const issueReasons = getShortLivedProviderPrepareModelIssueReasons({
providerId,
@ -1498,6 +1514,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
prepareChecks,
prepareRuntimeStatusSignature,
runtimeBackendSummaryByProvider,
selectedModelChecksByProviderSignature,
selectedMemberProviders,
]);
@ -1557,6 +1574,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
let checks = initialChecks;
const providerPlans = selectedMemberProviders.map((providerId) => {
const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
const selectedModelIds = selectedModelChecks.map((check) => check.model);
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
const cacheKey = buildProviderPrepareModelCacheKey({
cwd: effectiveCwd,
@ -1564,6 +1582,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
backendSummary,
limitContext: effectiveAnthropicRuntimeLimitContext,
runtimeStatusSignature: prepareRuntimeStatusSignature,
modelChecksSignature: selectedModelChecksByProviderSignature,
});
const cachedModelResultsById = {
...getShortLivedProviderPrepareModelResults({
@ -1574,12 +1593,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
};
const cachedSnapshot = getProviderPrepareCachedSnapshot({
providerId,
selectedModelIds: selectedModelChecks,
selectedModelIds,
cachedModelResultsById,
});
return {
providerId,
selectedModelChecks,
selectedModelIds,
backendSummary,
cacheKey,
cachedModelResultsById,
@ -1590,7 +1610,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
try {
for (const plan of providerPlans) {
checks = updateProviderCheck(checks, plan.providerId, {
status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking',
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
backendSummary: plan.backendSummary,
details: plan.cachedSnapshot.details,
});
@ -1603,7 +1623,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const prepResult = await runProviderPrepareDiagnostics({
cwd: effectiveCwd,
providerId: plan.providerId,
selectedModelIds: plan.selectedModelChecks,
selectedModelIds: plan.selectedModelIds,
selectedModelChecks: plan.selectedModelChecks,
prepareProvisioning: api.teams.prepareProvisioning,
limitContext: effectiveAnthropicRuntimeLimitContext,
cachedModelResultsById: plan.cachedModelResultsById,
@ -1669,14 +1690,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
anyFailure
? failureMessage
: anyNotes
? 'Selected providers are ready with notes.'
: 'Selected providers are ready.'
? 'All selected providers are ready, with notes.'
: 'All selected providers are ready.'
);
setPrepareWarnings(collectedWarnings);
} catch (error) {
if (prepareRequestSeqRef.current !== requestSeq) return;
const failureMessage =
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
error instanceof Error ? error.message : 'Failed to prepare selected providers';
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
@ -1692,6 +1713,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
selectedProviderId,
selectedMemberProviders,
selectedModelChecksByProvider,
selectedModelChecksByProviderSignature,
]);
// ---------------------------------------------------------------------------
@ -2832,7 +2854,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
id="dialog-effort"
providerId={selectedProviderId}
model={selectedModel}
limitContext={false}
limitContext={effectiveAnthropicRuntimeLimitContext}
/>
{selectedProviderId === 'anthropic' ? (
<div className="mt-2">
@ -2841,7 +2863,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
onValueChange={setSelectedFastMode}
providerFastModeDefault={anthropicProviderFastModeDefault}
model={selectedModel}
limitContext={false}
limitContext={effectiveAnthropicRuntimeLimitContext}
id="dialog-fast-mode"
/>
{anthropicRuntimeNotice ? (
@ -2951,7 +2973,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
<span>
{effectivePrepare.message ??
(effectivePrepare.state === 'idle'
? 'Warming up CLI environment...'
? 'Checking selected providers...'
: 'Preparing environment...')}
</span>
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
@ -2973,8 +2995,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
<span>
{prepareChecks.some((check) => check.status === 'notes') ||
prepareWarnings.length > 0
? 'CLI environment ready (with notes)'
: 'CLI environment ready'}
? 'Selected providers ready (with notes)'
: 'Selected providers ready'}
</span>
</div>
{effectivePrepare.message ? (

View file

@ -610,8 +610,8 @@ export function deriveEffectiveProvisioningPrepareState(params: {
return {
state: 'ready',
message: hasNotes
? 'Selected providers are ready with notes.'
: 'Selected providers are ready.',
? 'All selected providers are ready, with notes.'
: 'All selected providers are ready.',
};
}

View file

@ -6,12 +6,14 @@ export function buildProviderPrepareModelCacheKey({
backendSummary,
limitContext,
runtimeStatusSignature,
modelChecksSignature,
}: {
cwd: string;
providerId: TeamProviderId;
backendSummary: string | null | undefined;
limitContext: boolean;
runtimeStatusSignature?: string | null;
modelChecksSignature?: string | null;
}): string {
return [
cwd,
@ -19,5 +21,6 @@ export function buildProviderPrepareModelCacheKey({
backendSummary ?? '',
limitContext ? 'limit-context:on' : 'limit-context:off',
runtimeStatusSignature ?? '',
modelChecksSignature ?? '',
].join('::');
}

View file

@ -3,6 +3,7 @@ import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSele
import type {
TeamProviderId,
TeamProvisioningModelCheckRequest,
TeamProvisioningModelVerificationMode,
TeamProvisioningPrepareResult,
} from '@shared/types';
@ -15,7 +16,8 @@ type PrepareProvisioningFn = (
providerIds?: TeamProviderId[],
selectedModels?: string[],
limitContext?: boolean,
modelVerificationMode?: TeamProvisioningModelVerificationMode
modelVerificationMode?: TeamProvisioningModelVerificationMode,
selectedModelChecks?: TeamProvisioningModelCheckRequest[]
) => Promise<TeamProvisioningPrepareResult>;
interface ProviderPrepareDiagnosticsProgress {
@ -109,6 +111,44 @@ function buildModelSuccessLine(providerId: TeamProviderId, modelId: string): str
return `${getModelLabel(providerId, modelId)} - verified`;
}
function normalizeSelectedModelChecks(
providerId: TeamProviderId,
selectedModelIds: readonly string[],
selectedModelChecks?: readonly TeamProvisioningModelCheckRequest[]
): TeamProvisioningModelCheckRequest[] {
const rawChecks: TeamProvisioningModelCheckRequest[] =
selectedModelChecks && selectedModelChecks.length > 0
? [...selectedModelChecks]
: selectedModelIds.map((model) => ({ providerId, model }));
const seen = new Set<string>();
const normalized: TeamProvisioningModelCheckRequest[] = [];
for (const check of rawChecks) {
const model = check.model.trim();
if (!model) {
continue;
}
const key = `${check.providerId}\n${model}\n${check.effort ?? ''}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
normalized.push({
providerId: check.providerId,
model,
...(check.effort ? { effort: check.effort } : {}),
});
}
return normalized;
}
function selectModelChecksForIds(
modelChecks: readonly TeamProvisioningModelCheckRequest[],
modelIds: readonly string[]
): TeamProvisioningModelCheckRequest[] {
const modelIdSet = new Set(modelIds);
return modelChecks.filter((check) => modelIdSet.has(check.model));
}
function buildModelAvailableLine(providerId: TeamProviderId, modelId: string): string {
return `${getModelLabel(providerId, modelId)} - available for launch`;
}
@ -899,16 +939,25 @@ export async function runProviderPrepareDiagnostics({
limitContext,
onModelProgress,
cachedModelResultsById,
selectedModelChecks,
}: {
cwd: string;
providerId: TeamProviderId;
selectedModelIds: string[];
selectedModelChecks?: TeamProvisioningModelCheckRequest[];
prepareProvisioning: PrepareProvisioningFn;
limitContext?: boolean;
onModelProgress?: (progress: ProviderPrepareDiagnosticsProgress) => void;
cachedModelResultsById?: Record<string, ProviderPrepareDiagnosticsModelResult>;
}): Promise<ProviderPrepareDiagnosticsResult> {
if (selectedModelIds.length === 0) {
const normalizedModelChecks = normalizeSelectedModelChecks(
providerId,
selectedModelIds,
selectedModelChecks
);
const hasExplicitModelChecks = (selectedModelChecks?.length ?? 0) > 0;
const orderedModelIds = Array.from(new Set(normalizedModelChecks.map((check) => check.model)));
if (orderedModelIds.length === 0) {
const runtimeResult = await prepareProvisioning(
cwd,
providerId,
@ -936,9 +985,6 @@ export async function runProviderPrepareDiagnostics({
};
}
const orderedModelIds = Array.from(
new Set(selectedModelIds.map((modelId) => modelId.trim()).filter(Boolean))
);
const reusableModelResultsById = cachedModelResultsById ?? {};
const modelResultsById = new Map<string, ProviderPrepareDiagnosticsModelResult>();
const modelLines = new Map<string, string>();
@ -1039,7 +1085,10 @@ export async function runProviderPrepareDiagnostics({
[providerId],
uncachedModelIds,
limitContext,
'compatibility'
'compatibility',
...(hasExplicitModelChecks
? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)]
: [])
);
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
@ -1177,7 +1226,10 @@ export async function runProviderPrepareDiagnostics({
[providerId],
compatibilityPassedModelIds,
limitContext,
'deep'
'deep',
...(hasExplicitModelChecks
? [selectModelChecksForIds(normalizedModelChecks, compatibilityPassedModelIds)]
: [])
);
runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter(
(entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry)
@ -1328,7 +1380,10 @@ export async function runProviderPrepareDiagnostics({
[providerId],
uncachedModelIds,
limitContext,
'compatibility'
'compatibility',
...(hasExplicitModelChecks
? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)]
: [])
);
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)

View file

@ -1,8 +1,18 @@
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
import type { CliProviderStatus, TeamProviderId } from '@shared/types';
import type {
CliProviderStatus,
TeamProviderId,
TeamProvisioningModelCheckRequest,
} from '@shared/types';
type RuntimeProviderStatusById = ReadonlyMap<TeamProviderId, CliProviderStatus | null | undefined>;
type SelectedModelChecksByProvider = ReadonlyMap<TeamProviderId, readonly string[]>;
type ProviderModelCheckSignatureInput =
| string
| Pick<TeamProvisioningModelCheckRequest, 'model' | 'effort'>;
type SelectedModelChecksByProvider = ReadonlyMap<
TeamProviderId,
readonly ProviderModelCheckSignatureInput[]
>;
function normalizeModelIds(modelIds: readonly string[] | null | undefined): string[] {
return Array.from(
@ -10,6 +20,30 @@ function normalizeModelIds(modelIds: readonly string[] | null | undefined): stri
).sort();
}
function normalizeModelChecks(
checks: readonly ProviderModelCheckSignatureInput[] | null | undefined
): { model: string; effort: string | null }[] {
const seen = new Set<string>();
const normalized: { model: string; effort: string | null }[] = [];
for (const check of checks ?? []) {
const model = (typeof check === 'string' ? check : check.model).trim();
if (!model) {
continue;
}
const effort = typeof check === 'string' ? null : (check.effort ?? null);
const key = `${model}\n${effort ?? ''}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
normalized.push({ model, effort });
}
return normalized.sort(
(left, right) =>
left.model.localeCompare(right.model) || (left.effort ?? '').localeCompare(right.effort ?? '')
);
}
export function buildProviderPrepareMembersSignature(members: readonly MemberDraft[]): string {
return JSON.stringify(
members.map((member) => ({
@ -29,7 +63,10 @@ export function buildProviderPrepareModelChecksSignature(
Array.from(modelChecksByProvider.entries())
.map(([providerId, modelIds]) => ({
providerId,
modelIds: normalizeModelIds(modelIds),
modelIds: normalizeModelIds(
modelIds.map((modelId) => (typeof modelId === 'string' ? modelId : modelId.model))
),
modelChecks: normalizeModelChecks(modelIds),
}))
.sort((left, right) => left.providerId.localeCompare(right.providerId))
);

View file

@ -23,7 +23,10 @@ vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({
}));
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
formatTeamModelSummary: (providerId: string, model: string, effort?: string) =>
[providerId, model || 'Default', effort].filter(Boolean).join(' · '),
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
getTeamEffortLabel: (effort: string) => effort || 'Default',
getTeamProviderLabel: (providerId: string) => providerId,
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
}));

View file

@ -9,7 +9,9 @@ import {
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
import {
formatTeamModelSummary,
getProviderScopedTeamModelLabel,
getTeamEffortLabel,
getTeamProviderLabel,
TeamModelSelector,
} from '@renderer/components/team/dialogs/TeamModelSelector';
@ -111,6 +113,11 @@ export const LeadModelRow = ({
const contextLimitDisabled =
disableAnthropicContextLimit ??
(providerId === 'anthropic' && isAnthropicHaikuTeamModel(model));
const runtimeSummary = formatTeamModelSummary(providerId, model, effort);
const runtimeMeta = [
effort || providerId === 'anthropic' ? `Effort ${getTeamEffortLabel(effort ?? '')}` : null,
providerId === 'anthropic' ? (limitContext ? '200K context' : '1M-capable context') : null,
].filter((item): item is string => Boolean(item));
useEffect(() => {
if (hasActiveProviderNotice && !modelExpanded) {
@ -189,6 +196,12 @@ export const LeadModelRow = ({
{hasModelIssue ? <AlertTriangle className="size-3.5 shrink-0 text-red-300" /> : null}
{hasModelAdvisory ? <Info className="size-3.5 shrink-0 text-amber-300" /> : null}
</Button>
{runtimeMeta.length > 0 ? (
<p className="truncate px-1 text-[10px] leading-tight text-[var(--color-text-muted)]">
{runtimeMeta.join(' · ')}
<span className="sr-only">. {runtimeSummary}</span>
</p>
) : null}
</div>
</div>
{hasWarnings ? (

View file

@ -16,6 +16,7 @@ vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
formatTeamModelSummary: (providerId: string, model: string, effort?: string) =>
[providerId, model || 'Default', effort].filter(Boolean).join(' · '),
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
getTeamEffortLabel: (effort: string) => effort || 'Default',
getTeamProviderLabel: (providerId: string) => providerId,
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
}));
@ -32,6 +33,7 @@ vi.mock('@renderer/components/ui/button', () => ({
disabled,
title,
'aria-describedby': ariaDescribedBy,
'aria-expanded': ariaExpanded,
'aria-label': ariaLabel,
}: {
children: React.ReactNode;
@ -40,6 +42,7 @@ vi.mock('@renderer/components/ui/button', () => ({
disabled?: boolean;
title?: string;
'aria-describedby'?: string;
'aria-expanded'?: boolean;
'aria-label'?: string;
}) =>
React.createElement(
@ -51,6 +54,7 @@ vi.mock('@renderer/components/ui/button', () => ({
disabled,
title,
'aria-describedby': ariaDescribedBy,
'aria-expanded': ariaExpanded,
'aria-label': ariaLabel,
},
children
@ -174,6 +178,33 @@ describe('MemberDraftRow', () => {
});
});
it('renders the workflow control as an icon-only button with tooltip text', () => {
const { host, root } = renderMemberDraftRow({
showWorkflow: true,
onWorkflowChange: () => undefined,
});
const workflowButton = host.querySelector<HTMLButtonElement>(
'button[aria-label="Add teammate workflow"]'
)!;
expect(workflowButton).toBeTruthy();
expect(workflowButton.textContent).not.toContain('Workflow');
expect(workflowButton.closest('[title]')?.getAttribute('title')).toBe('Add teammate workflow');
expect(host.textContent).not.toContain('Workflow (optional)');
act(() => {
workflowButton.click();
});
expect(workflowButton.getAttribute('aria-expanded')).toBe('true');
expect(host.textContent).toContain('Workflow (optional)');
act(() => {
root.unmount();
});
});
it('shows inherited model copy when sync is enabled', () => {
const { host, root } = renderMemberDraftRow({
lockProviderModel: true,

View file

@ -6,6 +6,7 @@ import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLev
import {
formatTeamModelSummary,
getProviderScopedTeamModelLabel,
getTeamEffortLabel,
getTeamProviderLabel,
TeamModelSelector,
} from '@renderer/components/team/dialogs/TeamModelSelector';
@ -32,6 +33,7 @@ import {
Info,
RotateCcw,
Trash2,
Workflow as WorkflowIcon,
} from 'lucide-react';
import type { MemberDraft } from './membersEditorTypes';
@ -310,11 +312,24 @@ export const MemberDraftRow = ({
const anthropicContextModeLabel = limitContext
? '200K limit enabled'
: '1M-capable context allowed';
const workflowTooltipText = workflowDraft.value.trim()
? 'Edit teammate workflow'
: 'Add teammate workflow';
const runtimeSummary = formatTeamModelSummary(
effectiveProviderId,
effectiveModel?.trim() ?? '',
effectiveEffort
);
const runtimeMeta = [
effectiveEffort || effectiveProviderId === 'anthropic'
? `Effort ${getTeamEffortLabel(effectiveEffort ?? '')}`
: null,
effectiveProviderId === 'anthropic'
? limitContext
? '200K context'
: '1M-capable context'
: null,
].filter((item): item is string => Boolean(item));
return (
<div
@ -346,6 +361,7 @@ export const MemberDraftRow = ({
value={member.name}
aria-label={`Member ${index + 1} name`}
disabled={isRemoved || lockIdentity}
title={lockIdentity ? identityLockReason : undefined}
onChange={(event) => onNameChange(member.id, event.target.value)}
placeholder="member-name"
/>
@ -372,23 +388,31 @@ export const MemberDraftRow = ({
<div className="space-y-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
{showWorkflow && onWorkflowChange ? (
<Button
variant="outline"
size="sm"
className="relative h-8 shrink-0 gap-1"
disabled={isRemoved}
onClick={() => setWorkflowExpanded((prev) => !prev)}
<HoverTooltip
content={workflowTooltipText}
title={workflowTooltipText}
className="shrink-0"
contentClassName="max-w-64"
>
{workflowExpanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
Workflow
{!workflowExpanded && workflowDraft.value.trim() ? (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-blue-500" />
) : null}
</Button>
<Button
variant="outline"
size="sm"
className={cn(
'relative size-8 shrink-0 px-0',
workflowExpanded &&
'border-blue-400/50 bg-blue-500/10 text-blue-100 hover:bg-blue-500/15'
)}
aria-label={workflowTooltipText}
aria-expanded={workflowExpanded}
disabled={isRemoved}
onClick={() => setWorkflowExpanded((prev) => !prev)}
>
<WorkflowIcon className="size-3.5" />
{!workflowExpanded && workflowDraft.value.trim() ? (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-blue-500" />
) : null}
</Button>
</HoverTooltip>
) : null}
<div className="w-full min-w-0 space-y-1 sm:w-[150px] sm:min-w-[150px]">
<HoverTooltip
@ -426,6 +450,12 @@ export const MemberDraftRow = ({
{hasModelAdvisory ? <Info className="size-3.5 shrink-0 text-amber-300" /> : null}
</Button>
</HoverTooltip>
{runtimeMeta.length > 0 ? (
<p className="truncate px-1 text-[10px] leading-tight text-[var(--color-text-muted)]">
{runtimeMeta.join(' · ')}
<span className="sr-only">. {runtimeSummary}</span>
</p>
) : null}
{modelTooltipText ? (
<span id={modelHelpDescriptionId} className="sr-only">
{modelTooltipText}

View file

@ -246,7 +246,7 @@ export const CronScheduleInput = ({
</SelectContent>
</Select>
<p className="text-[11px] text-[var(--color-text-muted)]">
Pre-warms CLI environment before scheduled execution
Prepares selected providers before scheduled execution
</p>
</div>
</div>

View file

@ -652,7 +652,7 @@
var TEAM_MEMBER_OFFSETS = [0, 4, 7];
var TEAM_LABELS = ['Marketing', 'Researchers', 'Coding'];
var MAX_DPR = 2;
var AVATAR_URLS = [
var FALLBACK_AVATAR_URLS = [
'./assets/participant-avatars/01.png',
'./assets/participant-avatars/02.png',
'./assets/participant-avatars/03.png',
@ -667,9 +667,36 @@
'./assets/participant-avatars/12.png',
'./assets/participant-avatars/13.png',
];
var AVATAR_URLS = resolveAvatarUrls();
var avatarCache = new Map();
var avatarLoading = new Map();
function resolveAvatarUrls() {
var links = document.querySelectorAll(
'link[rel="preload"][as="image"][type="image/png"]'
);
var urlsByIndex = [];
for (var i = 0; i < links.length; i++) {
var href = links[i].getAttribute('href');
if (!href) continue;
var match = href.match(/(?:^|\/)(0[1-9]|1[0-3])(?:-[^/?#]+)?\.png(?:[?#].*)?$/);
if (!match) continue;
urlsByIndex[Number(match[1]) - 1] = href;
}
var resolved = [];
for (var avatarIndex = 0; avatarIndex < FALLBACK_AVATAR_URLS.length; avatarIndex++) {
var url = urlsByIndex[avatarIndex];
if (!url) return FALLBACK_AVATAR_URLS;
resolved.push(url);
}
return resolved;
}
function startFileSplashScene(splash) {
if (window.__claudeTeamsSplashScene && splash.querySelector('#splash-enhanced-canvas')) {
return window.__claudeTeamsSplashScene;

View file

@ -84,6 +84,7 @@ import type {
TeamLaunchResponse,
TeamMemberActivityMeta,
TeamMessageNotificationData,
TeamProvisioningModelCheckRequest,
TeamProvisioningModelVerificationMode,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
@ -494,7 +495,8 @@ export interface TeamsAPI {
providerIds?: TeamLaunchRequest['providerId'][],
selectedModels?: string[],
limitContext?: boolean,
modelVerificationMode?: TeamProvisioningModelVerificationMode
modelVerificationMode?: TeamProvisioningModelVerificationMode,
selectedModelChecks?: TeamProvisioningModelCheckRequest[]
) => Promise<TeamProvisioningPrepareResult>;
getWorktreeGitStatus: (projectPath: string) => Promise<TeamWorktreeGitStatus>;
initializeGitRepository: (projectPath: string) => Promise<TeamWorktreeGitStatus>;

View file

@ -1466,6 +1466,12 @@ export interface TeamCreateResponse {
export type TeamProvisioningModelVerificationMode = 'compatibility' | 'deep';
export interface TeamProvisioningModelCheckRequest {
providerId: TeamProviderId;
model: string;
effort?: EffortLevel;
}
export type TeamProvisioningPrepareIssueScope = 'provider' | 'model';
export type TeamProvisioningPrepareIssueSeverity = 'blocking' | 'warning';

View file

@ -1,13 +1,13 @@
import { describe, expect, it } from 'vitest';
import {
type AnthropicRuntimeProfileSource,
reconcileAnthropicRuntimeSelections,
resolveAnthropicEffortSupport,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '@features/anthropic-runtime-profile/renderer';
import type { CliProviderModelCatalog, CliProviderRuntimeCapabilities } from '@shared/types';
import { describe, expect, it } from 'vitest';
import type { AnthropicRuntimeProfileSource } from '@features/anthropic-runtime-profile/renderer';
import type { CliProviderModelCatalog, CliProviderRuntimeCapabilities } from '@shared/types';
function createAnthropicSource(options: {
models: CliProviderModelCatalog['models'];
@ -260,6 +260,77 @@ describe('resolveAnthropicRuntimeProfile', () => {
).toBe('Anthropic runtime capability data is still loading.');
});
it('allows known Opus 1M effort when catalog is unavailable but runtime capability passthrough is present', () => {
const selection = resolveAnthropicRuntimeSelection({
source: {
modelCatalog: null,
runtimeCapabilities: {
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high', 'max'],
configPassthrough: true,
},
},
},
selectedModel: 'claude-opus-4-6[1m]',
limitContext: false,
});
expect(
resolveAnthropicEffortSupport({
selection,
effort: 'medium',
runtimeCapabilities: {
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high', 'max'],
configPassthrough: true,
},
},
})
).toEqual({ kind: 'supported', source: 'runtime-capability' });
});
it('falls back to runtime launch model ids when catalog is unavailable', () => {
const selection = resolveAnthropicRuntimeSelection({
source: {
modelCatalog: null,
runtimeCapabilities: null,
},
selectedModel: 'claude-opus-4-6[1m]',
limitContext: false,
availableLaunchModels: ['claude-opus-4-6'],
});
expect(selection.resolvedLaunchModel).toBe('claude-opus-4-6');
expect(
resolveAnthropicEffortSupport({
selection,
effort: 'medium',
runtimeCapabilities: null,
})
).toEqual({ kind: 'supported', source: 'static-fallback' });
});
it('does not infer effort support for unknown Anthropic models without catalog truth', () => {
const selection = resolveAnthropicRuntimeSelection({
source: {
modelCatalog: null,
runtimeCapabilities: null,
},
selectedModel: 'claude-experimental-5',
limitContext: false,
});
expect(
resolveAnthropicEffortSupport({
selection,
effort: 'medium',
runtimeCapabilities: null,
})
).toEqual({ kind: 'unverified-catalog-missing' });
});
it('keeps the fast control visible in degraded states and surfaces the provider reason', () => {
const selection = resolveAnthropicRuntimeSelection({
source: createAnthropicSource({

View file

@ -462,6 +462,65 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(true);
});
it('forwards selected model checks with effort to prepareProvisioning', async () => {
const handler = handlers.get(TEAM_PREPARE_PROVISIONING)!;
const result = (await handler(
{ sender: { send: vi.fn() } } as never,
os.tmpdir(),
'anthropic',
['anthropic'],
['claude-opus-4-6[1m]'],
false,
'compatibility',
[
{
providerId: 'anthropic',
model: 'claude-opus-4-6[1m]',
effort: 'medium',
},
]
)) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.prepareForProvisioning).toHaveBeenCalledWith(os.tmpdir(), {
providerId: 'anthropic',
providerIds: ['anthropic'],
modelIds: ['claude-opus-4-6[1m]'],
limitContext: false,
modelVerificationMode: 'compatibility',
modelChecks: [
{
providerId: 'anthropic',
model: 'claude-opus-4-6[1m]',
effort: 'medium',
},
],
});
});
it('rejects invalid selected model check effort for the provider', async () => {
const handler = handlers.get(TEAM_PREPARE_PROVISIONING)!;
const result = (await handler(
{ sender: { send: vi.fn() } } as never,
os.tmpdir(),
'anthropic',
['anthropic'],
['claude-opus-4-6[1m]'],
false,
'compatibility',
[
{
providerId: 'anthropic',
model: 'claude-opus-4-6[1m]',
effort: 'xhigh',
},
]
)) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain('selectedModelChecks effort must be one of');
});
it('updates change presence tracking for a team', async () => {
const handler = handlers.get(TEAM_SET_CHANGE_PRESENCE_TRACKING);
expect(handler).toBeDefined();

View file

@ -13,8 +13,8 @@ const execCliMock = vi.fn();
const buildProviderAwareCliEnvMock = vi.fn();
const resolveInteractiveShellEnvMock = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise<string>>();
const enrichProviderStatusMock = vi.fn(
(provider, _options?: { hydrateModelCatalog?: boolean }) => Promise.resolve(provider)
const enrichProviderStatusMock = vi.fn((provider, _options?: { hydrateModelCatalog?: boolean }) =>
Promise.resolve(provider)
);
const enrichProviderStatusesMock = vi.fn((providers) => Promise.resolve(providers));
@ -343,6 +343,42 @@ describe('ClaudeMultimodelBridgeService', () => {
expect(calls).not.toContain('model list --json --provider all');
});
it('returns a scoped provider error when single-provider summary status times out', async () => {
execCliMock.mockImplementation((_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
if (normalizedArgs === 'runtime status --json --provider codex --summary') {
return Promise.reject(
new Error(
'Command timed out after 25000ms: /mock/agent_teams_orchestrator runtime status --json --provider codex --summary'
)
);
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const provider = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'codex');
const calls = execCliMock.mock.calls.map((call) => call[1].join(' '));
expect(provider).toMatchObject({
providerId: 'codex',
verificationState: 'error',
statusMessage: 'Provider status unavailable',
});
expect(provider.detailMessage).toContain('Command timed out after 25000ms');
expect(calls).toEqual(['runtime status --json --provider codex --summary']);
expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([
expect.stringContaining(
'Provider-scoped summary runtime status unavailable for codex, returning scoped error'
),
]);
vi.mocked(console.warn).mockClear();
});
it('loads frontend providers with parallel provider-scoped runtime status probes', async () => {
const providerPayloads = {
anthropic: {
@ -439,9 +475,7 @@ describe('ClaudeMultimodelBridgeService', () => {
).toEqual([8 * 1024 * 1024, 8 * 1024 * 1024, 8 * 1024 * 1024]);
expect(enrichProviderStatusMock).toHaveBeenCalledTimes(3);
expect(
enrichProviderStatusMock.mock.calls.every(
(call) => call[1]?.hydrateModelCatalog === false
)
enrichProviderStatusMock.mock.calls.every((call) => call[1]?.hydrateModelCatalog === false)
).toBe(true);
expect(providers.map((provider) => provider.providerId)).toEqual([
'anthropic',
@ -506,10 +540,7 @@ describe('ClaudeMultimodelBridgeService', () => {
? (args[providerArgIndex + 1] as keyof typeof summaryPayloads)
: null;
if (
normalizedArgs === 'runtime status --json --provider codex' &&
providerId === 'codex'
) {
if (normalizedArgs === 'runtime status --json --provider codex' && providerId === 'codex') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
@ -711,6 +742,157 @@ describe('ClaudeMultimodelBridgeService', () => {
});
});
it('queues fresh single-provider catalog hydration behind an in-flight one', async () => {
let resolveHydration!: (value: { stdout: string; stderr: string; exitCode: number }) => void;
const hydration = new Promise<{ stdout: string; stderr: string; exitCode: number }>(
(resolve) => {
resolveHydration = resolve;
}
);
let fullStatusCalls = 0;
execCliMock.mockImplementation((_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
if (normalizedArgs === 'runtime status --json --provider codex --summary') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
codex: {
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: true,
authMethod: 'api_key',
verificationState: 'verified',
canLoginFromUi: false,
statusMessage: null,
models: ['gpt-5.4'],
capabilities: { teamLaunch: true, oneShot: true },
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
},
},
}),
stderr: '',
exitCode: 0,
});
}
if (normalizedArgs === 'runtime status --json --provider codex') {
fullStatusCalls += 1;
if (fullStatusCalls === 1) {
return hydration;
}
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
codex: {
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
canLoginFromUi: false,
statusMessage: 'fresh full status should not overwrite live summary',
models: ['gpt-5.4'],
capabilities: { teamLaunch: true, oneShot: true },
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
modelCatalog: {
schemaVersion: 1,
providerId: 'codex',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-17T00:01:00.000Z',
staleAt: '2026-05-17T00:11:00.000Z',
defaultModelId: 'fresh-model',
defaultLaunchModel: 'fresh-model',
models: [],
diagnostics: {
configReadState: 'skipped',
appServerState: 'healthy',
},
},
},
},
}),
stderr: '',
exitCode: 0,
});
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const firstUpdate = vi.fn();
const secondUpdate = vi.fn();
await service.getProviderStatus('/mock/agent_teams_orchestrator', 'codex', firstUpdate);
await service.getProviderStatus('/mock/agent_teams_orchestrator', 'codex', secondUpdate);
expect(
execCliMock.mock.calls.filter(
(call) => call[1].join(' ') === 'runtime status --json --provider codex'
)
).toHaveLength(1);
resolveHydration({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
codex: {
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
canLoginFromUi: false,
statusMessage: 'full status should not overwrite live summary',
models: ['gpt-5.4'],
capabilities: { teamLaunch: true, oneShot: true },
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
modelCatalog: {
schemaVersion: 1,
providerId: 'codex',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-17T00:00:00.000Z',
staleAt: '2026-05-17T00:10:00.000Z',
defaultModelId: 'gpt-5.4',
defaultLaunchModel: 'gpt-5.4',
models: [],
diagnostics: {
configReadState: 'skipped',
appServerState: 'healthy',
},
},
},
},
}),
stderr: '',
exitCode: 0,
});
await vi.waitFor(() => {
expect(secondUpdate).toHaveBeenCalledTimes(1);
});
expect(fullStatusCalls).toBe(2);
expect(firstUpdate).not.toHaveBeenCalled();
expect(secondUpdate.mock.calls[0]?.[0]).toMatchObject({
authenticated: true,
authMethod: 'api_key',
statusMessage: null,
modelCatalog: {
defaultModelId: 'fresh-model',
},
});
});
it('hydrates Anthropic subscription rate limits after the live summary status', async () => {
execCliMock.mockImplementation((_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
@ -1161,9 +1343,8 @@ describe('ClaudeMultimodelBridgeService', () => {
const hasOldCatalogUpdate = [...firstUpdates.mock.calls, ...secondUpdates.mock.calls].some(
([providers]) =>
providers
.find((provider) => provider.providerId === 'codex')
?.modelCatalog?.defaultModelId === 'old-model'
providers.find((provider) => provider.providerId === 'codex')?.modelCatalog
?.defaultModelId === 'old-model'
);
expect(hasOldCatalogUpdate).toBe(false);
});

View file

@ -35,10 +35,11 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
const DEFAULT_LEAD_MODEL = 'sonnet';
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_LEAD_MODEL = 'claude-opus-4-6[1m]';
const DEFAULT_MEMBER_MODEL = 'haiku';
const DEFAULT_LEAD_EFFORT = 'low' as const;
const DEFAULT_LEAD_EFFORT = 'medium' as const;
liveDescribe('Anthropic launch selection live e2e', () => {
let tempDir: string;
@ -165,9 +166,10 @@ liveDescribe('Anthropic launch selection live e2e', () => {
if (subscriptionAuth && teamName) {
await removeTeamArtifacts(teamName);
}
discardKnownAnthropicLaunchSelectionWarnings();
}, 180_000);
it('launches Sonnet low lead with explicit Haiku teammate without inherited effort', async () => {
it('launches Opus 4.6 1M medium lead with explicit Haiku teammate without inherited effort', async () => {
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
expect(orchestratorCli).toBeTruthy();
await assertExecutable(orchestratorCli!);
@ -191,6 +193,7 @@ liveDescribe('Anthropic launch selection live e2e', () => {
model: leadModel,
effort: leadEffort,
skipPermissions: true,
extraCliArgs: "--settings '{\"disableAllHooks\":true}'",
prompt: 'Keep the team idle after bootstrap. Do not start extra work.',
members: [
{
@ -286,6 +289,18 @@ function restoreEnv(name: string, previous: string | undefined): void {
}
}
function discardKnownAnthropicLaunchSelectionWarnings(): void {
const warn = vi.mocked(console.warn);
if (!warn.mock) return;
const calls = warn.mock.calls;
for (let index = calls.length - 1; index >= 0; index -= 1) {
const text = calls[index]?.map((value) => String(value)).join(' ') ?? '';
if (text.includes('Failed to resolve login shell env: shell env resolve timeout')) {
calls.splice(index, 1);
}
}
}
async function assertExecutable(filePath: string): Promise<void> {
await fs.access(filePath, fsConstants.X_OK);
}

View file

@ -25,7 +25,8 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'haiku';
liveDescribe('Anthropic runtime memory live e2e', () => {

View file

@ -48,7 +48,8 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'sonnet';
const DEFAULT_EFFORT = 'low' as const;

View file

@ -45,7 +45,8 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'gpt-5.4-mini';
const DEFAULT_EFFORT = 'low' as const;

View file

@ -36,7 +36,8 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini';
const DEFAULT_OPENCODE_MODEL = 'openai/gpt-5.4-mini';

View file

@ -31,7 +31,8 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'opencode/big-pickle';
liveDescribe('OpenCode accept-fast delivery live e2e', () => {

View file

@ -47,7 +47,8 @@ const liveDescribe =
const liveMultiLaneIt = process.env.OPENCODE_E2E_MIXED_RECOVERY_MULTI === '1' ? it : it.skip;
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'opencode/big-pickle';
liveDescribe('OpenCode mixed recovery live e2e', () => {

View file

@ -37,7 +37,8 @@ const liveDescribe =
: describe.skip;
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_MODEL = 'opencode/big-pickle';
liveDescribe('OpenCode team provisioning live e2e', () => {

View file

@ -5,16 +5,17 @@ import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createOpenCodeLiveHarness, waitForOpenCodeLanesStopped, waitUntil } from './openCodeLiveTestHarness';
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
import {
getTasksBasePath,
getTeamsBasePath,
setClaudeBasePathOverride,
} from '../../../../src/main/utils/pathDecoder';
import { killProcessByPid } from '../../../../src/main/utils/processKill';
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
import { createOpenCodeLiveHarness, waitForOpenCodeLanesStopped, waitUntil } from './openCodeLiveTestHarness';
import type {
TeamAgentRuntimeSnapshot,
@ -37,13 +38,35 @@ const liveDescribe =
? describe
: describe.skip;
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
const DEFAULT_ANTHROPIC_MODEL = 'haiku';
const DEFAULT_CODEX_MODEL = 'gpt-5.4-mini';
const DEFAULT_CODEX_EFFORT = 'low' as const;
const DEFAULT_OPENCODE_MODEL = 'openai/gpt-5.4-mini';
const DEFAULT_ORDER: ProviderLaunchStressScenario[] = ['anthropic', 'codex', 'opencode', 'mixed'];
const MEMBER_NAMES = ['alice', 'bob', 'jack', 'tom', 'atlas', 'nova', 'cody', 'oscar'];
const MEMBER_NAMES = [
'alice',
'bob',
'jack',
'tom',
'atlas',
'nova',
'cody',
'oscar',
'maya',
'liam',
'ivy',
'noah',
'zoe',
'ryan',
'emma',
'owen',
'luna',
'finn',
'aria',
'milo',
];
const RESTART_CONFIRM_TIMEOUT_MS = 300_000;
const POST_LAUNCH_WORK_TIMEOUT_MS = 300_000;
let currentStressTempDir = '';
@ -159,7 +182,7 @@ liveDescribe('provider launch stress live e2e', () => {
}, 240_000);
it(
'launches, restarts, and exercises post-launch work for provider teams with five teammates each',
'launches, restarts, and exercises post-launch work for provider teams with the requested teammate count',
async () => {
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
expect(orchestratorCli).toBeTruthy();
@ -495,6 +518,7 @@ function buildStressCreateRequest(input: {
effort: providerId === 'codex' ? input.selection.codexEffort : undefined,
fastMode: providerId === 'codex' ? 'off' : undefined,
skipPermissions: true,
extraCliArgs: process.env.PROVIDER_LAUNCH_STRESS_EXTRA_CLI_ARGS?.trim() || undefined,
prompt: 'Keep the team idle after bootstrap. Do not start extra work.',
members,
};
@ -534,7 +558,7 @@ function resolveStressMemberProvider(
return providers[index % providers.length] ?? 'anthropic';
}
function resolveScenarioSelection(scenario: ProviderLaunchStressScenario): {
function resolveScenarioSelection(_scenario: ProviderLaunchStressScenario): {
anthropicModel: string;
codexModel: string;
codexEffort: 'low' | 'medium' | 'high' | 'xhigh';

View file

@ -177,6 +177,25 @@ describe('TeamLaunchFailureArtifactPack', () => {
});
});
it('does not classify stdin warning as root cause after bootstrap transport evidence', () => {
const input = {
teamName: 'artifact-team',
runId: 'run-mailbox-written',
reason:
'atlas: Teammate process atlas@signal-ops did not submit bootstrap prompt: timed out waiting for bootstrap_submitted; last transport stage: mailbox_bootstrap_written Last stderr: Warning: no stdin data received in 3s, proceeding without it.',
progressTraceLines: [
'mailbox_bootstrap_written detail=messageId=bootstrap-atlas-1',
'Warning: no stdin data received in 3s, proceeding without it.',
],
};
expect(classifyLaunchFailureArtifact(input).code).toBe('model_no_bootstrap');
expect(extractLaunchBootstrapTransportBreadcrumb(input)).toMatchObject({
noStdinWarning: true,
bootstrapSubmitted: false,
});
});
it('classifies provider quota separately from protocol errors', () => {
expect(
classifyLaunchFailureArtifact({

View file

@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs';
import Module from 'module';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type ExecCliMock = (
binaryPath: string | null,
@ -58,11 +58,11 @@ vi.mock('@main/utils/shellEnv', async (importOriginal) => {
};
});
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import {
TeamMcpConfigBuilder,
clearResolvedNodePathForTests,
TeamMcpConfigBuilder,
} from '@main/services/team/TeamMcpConfigBuilder';
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
describe('TeamMcpConfigBuilder', () => {
const createdPaths: string[] = [];
@ -495,6 +495,58 @@ describe('TeamMcpConfigBuilder', () => {
expectNodeTsxSourceEntry(parsed.mcpServers['agent-teams'], tsxCli, sourceEntry);
});
it('forces the generated agent-teams MCP server on regardless of user, local, or project settings', async () => {
const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable();
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
createdDirs.push(homeDir, projectDir);
mockHomeDir = homeDir;
fs.writeFileSync(
path.join(homeDir, '.claude.json'),
JSON.stringify(
{
mcpServers: {
'agent-teams': { command: 'node', args: ['user-disabled.js'], enabled: false },
},
projects: {
[projectDir]: {
mcpServers: {
'agent-teams': { command: 'node', args: ['local-disabled.js'], enabled: false },
},
},
},
},
null,
2
)
);
fs.writeFileSync(
path.join(projectDir, '.mcp.json'),
JSON.stringify(
{
mcpServers: {
'agent-teams': { command: 'node', args: ['project-disabled.js'], enabled: false },
},
},
null,
2
)
);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile(projectDir);
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[]; enabled?: boolean }>;
};
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
expect(parsed.mcpServers['agent-teams']?.enabled).toBe(true);
expectNodeTsxSourceEntry(parsed.mcpServers['agent-teams'], tsxCli, sourceEntry);
});
it('passes the configured Claude root to the MCP server', async () => {
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-claude-root-'));
createdDirs.push(claudeRoot);

View file

@ -1,10 +1,9 @@
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
ClaudeBinaryResolver: { resolve: vi.fn() },
@ -95,13 +94,13 @@ vi.mock('@main/utils/childProcess', () => ({
killProcessTree: vi.fn(),
}));
import {
TeamProvisioningService,
buildDirectTmuxRestartEnvAssignments,
} from '@main/services/team/TeamProvisioningService';
import { ProviderConnectionService } from '@main/services/runtime/ProviderConnectionService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime';
import { ProviderConnectionService } from '@main/services/runtime/ProviderConnectionService';
import {
buildDirectTmuxRestartEnvAssignments,
TeamProvisioningService,
} from '@main/services/team/TeamProvisioningService';
import { spawnCli } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
@ -667,7 +666,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
limitContext: false,
facts,
})
).toThrow('does not support it in the current runtime');
).toThrow('does not support Anthropic effort "low" in the current runtime');
});
afterEach(async () => {
@ -2394,6 +2393,81 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(spawnProbe).not.toHaveBeenCalled();
});
it('allows selected Anthropic effort checks when model catalog is missing but model is known', async () => {
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
env: {
PATH: '/usr/bin',
SHELL: '/bin/zsh',
},
authSource: 'none',
geminiRuntimeAuth: null,
providerArgs: [],
});
vi.spyOn(svc as any, 'readRuntimeProviderLaunchFacts').mockResolvedValue({
defaultModel: null,
modelIds: new Set(['claude-opus-4-6[1m]']),
modelCatalog: null,
runtimeCapabilities: null,
providerStatus: null,
});
const result = await (svc as any).verifySelectedProviderModels({
claudePath: '/fake/claude',
cwd: tempRoot,
providerId: 'anthropic',
modelIds: ['claude-opus-4-6[1m]'],
modelChecks: [{ modelId: 'claude-opus-4-6[1m]', effort: 'medium' }],
limitContext: false,
});
expect(result.details).toEqual([
'Selected model claude-opus-4-6[1m] is available for launch.',
]);
expect(result.blockingMessages).toEqual([]);
});
it('blocks selected Anthropic effort checks when model catalog cannot verify an unknown model', async () => {
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
env: {
PATH: '/usr/bin',
SHELL: '/bin/zsh',
},
authSource: 'none',
geminiRuntimeAuth: null,
providerArgs: [],
});
vi.spyOn(svc as any, 'readRuntimeProviderLaunchFacts').mockResolvedValue({
defaultModel: null,
modelIds: new Set(['claude-experimental-5']),
modelCatalog: null,
runtimeCapabilities: null,
providerStatus: null,
});
const result = await (svc as any).verifySelectedProviderModels({
claudePath: '/fake/claude',
cwd: tempRoot,
providerId: 'anthropic',
modelIds: ['claude-experimental-5'],
modelChecks: [{ modelId: 'claude-experimental-5', effort: 'medium' }],
limitContext: false,
});
expect(result.details).toEqual([]);
expect(result.blockingMessages).toEqual([
'Selected model claude-experimental-5 is unavailable. Anthropic runtime catalog was unavailable, so effort "medium" for claude-experimental-5 could not be verified.',
]);
expect(result.issues).toEqual([
expect.objectContaining({
providerId: 'anthropic',
modelId: 'claude-experimental-5',
code: 'effort_unverified',
}),
]);
});
it('augments dynamic Codex compatibility checks with the app-server catalog', async () => {
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
@ -3422,7 +3496,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
limitContext: false,
facts,
})
).toThrow('does not support it in the current runtime');
).toThrow('does not support Anthropic effort "max" in the current runtime');
expect(() =>
(svc as any).validateRuntimeLaunchSelection({
@ -3436,6 +3510,75 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
).toThrow('enables Anthropic Fast mode');
});
it('allows known Anthropic effort when runtime catalog is unavailable', () => {
const svc = new TeamProvisioningService();
const facts = {
defaultModel: null,
modelIds: new Set<string>(),
modelCatalog: null,
runtimeCapabilities: {
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high', 'max'],
configPassthrough: true,
},
},
};
expect(() =>
(svc as any).validateRuntimeLaunchSelection({
actorLabel: 'Team lead',
providerId: 'anthropic',
model: 'claude-opus-4-6[1m]',
effort: 'medium',
limitContext: false,
facts,
})
).not.toThrow();
});
it('allows known Anthropic effort when catalog is missing and model list only exposes the base launch id', () => {
const svc = new TeamProvisioningService();
const facts = {
defaultModel: null,
modelIds: new Set(['claude-opus-4-6']),
modelCatalog: null,
runtimeCapabilities: null,
};
expect(() =>
(svc as any).validateRuntimeLaunchSelection({
actorLabel: 'Team lead',
providerId: 'anthropic',
model: 'claude-opus-4-6[1m]',
effort: 'medium',
limitContext: false,
facts,
})
).not.toThrow();
});
it('reports unknown Anthropic effort support as unverified when runtime catalog is unavailable', () => {
const svc = new TeamProvisioningService();
const facts = {
defaultModel: null,
modelIds: new Set<string>(),
modelCatalog: null,
runtimeCapabilities: null,
};
expect(() =>
(svc as any).validateRuntimeLaunchSelection({
actorLabel: 'Team lead',
providerId: 'anthropic',
model: 'claude-experimental-5',
effort: 'medium',
limitContext: false,
facts,
})
).toThrow('could not be verified');
});
it('emits a lead-message refresh after provisioning reaches ready', async () => {
const svc = new TeamProvisioningService();
const emitter = vi.fn();

View file

@ -146,6 +146,7 @@ function extractBootstrapSpec(callIndex = 0): {
team?: { name?: string; cwd?: string };
lead?: { permissionSeedTools?: string[] };
members?: Array<Record<string, unknown>>;
launch?: { bootstrapTimeoutMs?: number; continueOnPartialFailure?: boolean };
} {
const args = vi.mocked(spawnCli).mock.calls[callIndex]?.[1] as string[] | undefined;
const specFlagIndex = args?.indexOf('--team-bootstrap-spec') ?? -1;
@ -158,6 +159,7 @@ function extractBootstrapSpec(callIndex = 0): {
team?: { name?: string; cwd?: string };
lead?: { permissionSeedTools?: string[] };
members?: Array<Record<string, unknown>>;
launch?: { bootstrapTimeoutMs?: number; continueOnPartialFailure?: boolean };
};
}
@ -348,10 +350,61 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
cwd: process.cwd(),
}),
]);
expect(bootstrapSpec.launch).toMatchObject({
bootstrapTimeoutMs: 120_000,
continueOnPartialFailure: true,
});
await svc.cancelProvisioning(runId);
});
it('createTeam scales deterministic bootstrap timeout with member count', async () => {
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
const { child } = createFakeChild();
vi.mocked(spawnCli).mockReturnValue(child as any);
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
const svc = new TeamProvisioningService();
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: { ANTHROPIC_API_KEY: 'test' },
authSource: 'anthropic_api_key',
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).pathExists = vi.fn(async () => false);
let runId: string | undefined;
try {
const created = await svc.createTeam(
{
teamName: 'large-team',
cwd: process.cwd(),
members: [
{ name: 'alice' },
{ name: 'atlas' },
{ name: 'bob' },
{ name: 'jack' },
{ name: 'tom' },
],
},
() => {}
);
runId = created.runId;
expect(extractBootstrapSpec().launch).toMatchObject({
bootstrapTimeoutMs: 300_000,
continueOnPartialFailure: true,
});
expect(setTimeoutSpy.mock.calls.some((call) => call[1] === 330_000)).toBe(true);
} finally {
if (runId) {
await svc.cancelProvisioning(runId);
}
setTimeoutSpy.mockRestore();
}
});
it('createTeam bootstrap spec includes worktree isolation only for selected teammates', async () => {
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
const { child } = createFakeChild();

View file

@ -33,7 +33,8 @@ import { getClaudeBasePath, getTeamsBasePath } from '../../../../src/main/utils/
import type { HttpServices } from '../../../../src/main/http';
import type { TaskRef } from '../../../../src/shared/types';
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
const DEFAULT_ORCHESTRATOR_CLI =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
export interface InboxMessage {
from?: string;

View file

@ -546,6 +546,44 @@ describe('cli child process helpers', () => {
vi.useRealTimers();
}
});
it('kills a POSIX launcher, Bun child, and nested shell on execFile timeout', async () => {
setPlatform('darwin');
vi.useFakeTimers();
const execFileMock = child.execFile as unknown as Mock;
const spawnSyncMock = child.spawnSync as unknown as Mock;
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const childProcess = new EventEmitter() as EventEmitter & {
pid: number;
stdout: EventEmitter;
stderr: EventEmitter;
};
childProcess.pid = 500;
childProcess.stdout = new EventEmitter();
childProcess.stderr = new EventEmitter();
spawnSyncMock.mockReturnValue({
status: 0,
stdout: ['500 1', '501 500', '502 501'].join('\n'),
});
execFileMock.mockImplementation(() => childProcess);
try {
const result = execCli('/tmp/cli-dev', ['runtime', 'status', '--json'], { timeout: 100 });
const expectation = expect(result).rejects.toMatchObject({
killed: true,
signal: 'SIGTERM',
});
await vi.advanceTimersByTimeAsync(100);
await expectation;
expect(killSpy.mock.calls.map(([pid]) => pid)).toEqual(
expect.arrayContaining([500, 501, 502])
);
} finally {
killSpy.mockRestore();
vi.useRealTimers();
}
});
});
describe('killProcessTree', () => {

View file

@ -1896,7 +1896,7 @@ describe('LaunchTeamDialog', () => {
.mocked(runProviderPrepareDiagnostics)
.mock.calls.filter((call) => call[0]?.providerId === 'opencode');
expect(inFlightOpencodePrepareCalls).toHaveLength(1);
expect(host.textContent).toContain('Selected providers are ready.');
expect(host.textContent).toContain('All selected providers are ready.');
await act(async () => {
root.unmount();
@ -2046,7 +2046,7 @@ describe('LaunchTeamDialog', () => {
expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalledTimes(
callsAfterSameSignatureRerender
);
expect(host.textContent).toContain('Selected providers are ready.');
expect(host.textContent).toContain('All selected providers are ready.');
await act(async () => {
root.unmount();

View file

@ -520,7 +520,7 @@ describe('ProvisioningProviderStatusList', () => {
})
).toEqual({
state: 'ready',
message: 'Selected providers are ready.',
message: 'All selected providers are ready.',
});
});

View file

@ -1,11 +1,10 @@
import { describe, expect, it, vi } from 'vitest';
import {
buildReusableProviderPrepareModelResults,
mergeReusableProviderPrepareModelResults,
runProviderPrepareDiagnostics,
} from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import { describe, expect, it, vi } from 'vitest';
import type { TeamProviderId, TeamProvisioningPrepareResult } from '@shared/types';
@ -96,6 +95,47 @@ describe('runProviderPrepareDiagnostics', () => {
});
});
it('passes selected model effort checks through compatibility preflight', async () => {
const prepareProvisioning = vi.fn(async (): Promise<TeamProvisioningPrepareResult> => ({
ready: true,
message: 'ready',
details: ['Selected model claude-opus-4-6[1m] is available for launch.'],
}));
const result = await runProviderPrepareDiagnostics({
cwd: '/tmp/project',
providerId: 'anthropic',
selectedModelIds: ['claude-opus-4-6[1m]'],
selectedModelChecks: [
{
providerId: 'anthropic',
model: 'claude-opus-4-6[1m]',
effort: 'medium',
},
],
prepareProvisioning,
limitContext: false,
});
expect(result.status).toBe('ready');
expect(prepareProvisioning).toHaveBeenNthCalledWith(
1,
'/tmp/project',
'anthropic',
['anthropic'],
['claude-opus-4-6[1m]'],
false,
'compatibility',
[
{
providerId: 'anthropic',
model: 'claude-opus-4-6[1m]',
effort: 'medium',
},
]
);
});
it('removes a stale reusable model result when the latest result is advisory', () => {
expect(
mergeReusableProviderPrepareModelResults(

View file

@ -1,11 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
buildProviderPrepareMembersSignature,
buildProviderPrepareModelChecksSignature,
buildProviderPrepareRequestSignature,
buildProviderPrepareRuntimeStatusSignature,
} from '@renderer/components/team/dialogs/providerPrepareRequestSignature';
import { describe, expect, it } from 'vitest';
describe('providerPrepareRequestSignature', () => {
it('stays stable for semantically identical provider runtime snapshots', () => {
@ -573,4 +572,17 @@ describe('providerPrepareRequestSignature', () => {
})
);
});
it('changes model checks signature when selected effort changes', () => {
const medium = buildProviderPrepareModelChecksSignature(
new Map([['anthropic', [{ model: 'claude-opus-4-6[1m]', effort: 'medium' }]]])
);
const high = buildProviderPrepareModelChecksSignature(
new Map([['anthropic', [{ model: 'claude-opus-4-6[1m]', effort: 'high' }]]])
);
expect(medium).not.toBe(high);
expect(medium).toContain('"effort":"medium"');
expect(high).toContain('"effort":"high"');
});
});