fix: harden team launch bootstrap provisioning
This commit is contained in:
parent
85959b6954
commit
bf3011624d
53 changed files with 1720 additions and 235 deletions
|
|
@ -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.
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -256,6 +256,10 @@
|
|||
background: var(--cyber-hero-bg);
|
||||
}
|
||||
|
||||
#hero.cyber-hero {
|
||||
padding-top: 120px;
|
||||
}
|
||||
|
||||
.cyber-hero__background,
|
||||
.cyber-hero__monterey,
|
||||
.cyber-hero__wash,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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%),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -432,6 +432,7 @@ export class TeamMcpConfigBuilder {
|
|||
[MCP_SERVER_NAME]: {
|
||||
command: launchSpec.command,
|
||||
args: launchSpec.args,
|
||||
enabled: true,
|
||||
env: {
|
||||
[MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)', {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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('::');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@ describe('ProvisioningProviderStatusList', () => {
|
|||
})
|
||||
).toEqual({
|
||||
state: 'ready',
|
||||
message: 'Selected providers are ready.',
|
||||
message: 'All selected providers are ready.',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue